From d8d9033eb169c0285b8c018b2bd86fa7392322c1 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 3 Apr 2024 11:10:26 -0700 Subject: [PATCH 1/9] Extract a hook for much of the gRPC form logic --- .../src/hooks/use-grpc-endpoint-form.ts | 66 ++++++++++++++++ .../routes/popup/settings/settings-rpc.tsx | 76 ++++++++----------- 2 files changed, 96 insertions(+), 46 deletions(-) create mode 100644 apps/extension/src/hooks/use-grpc-endpoint-form.ts diff --git a/apps/extension/src/hooks/use-grpc-endpoint-form.ts b/apps/extension/src/hooks/use-grpc-endpoint-form.ts new file mode 100644 index 0000000000..56bc68e54f --- /dev/null +++ b/apps/extension/src/hooks/use-grpc-endpoint-form.ts @@ -0,0 +1,66 @@ +import { createPromiseClient } from '@connectrpc/connect'; +import { QueryService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/app/v1/app_connect'; +import { createGrpcWebTransport } from '@connectrpc/connect-web'; +import { useState } from 'react'; +import { AllSlices } from '../state'; +import { useStoreShallow } from '../utils/use-store-shallow'; +import { useChainIdQuery } from './chain-id'; +import { ServicesMessage } from '@penumbra-zone/types/src/services'; + +const useSaveGrpcEndpointSelector = (state: AllSlices) => ({ + grpcEndpoint: state.network.grpcEndpoint, + setGrpcEndpoint: state.network.setGRPCEndpoint, +}); + +/** + * Provides everything needed for a gRPC endpoint picking form. + */ +export const useGrpcEndpointForm = () => { + const { chainId: currentChainId } = useChainIdQuery(); + const [newChainId, setNewChainId] = useState(); + const { grpcEndpoint, setGrpcEndpoint } = useStoreShallow(useSaveGrpcEndpointSelector); + const [grpcEndpointInput, setGrpcEndpointInput] = useState(grpcEndpoint ?? ''); + const [rpcError, setRpcError] = useState(); + + const onSubmit = async ( + /** Callback to run when the RPC endpoint successfully saves */ + onSuccess?: () => unknown, + ) => { + try { + const trialClient = createPromiseClient( + QueryService, + createGrpcWebTransport({ baseUrl: grpcEndpointInput }), + ); + const { appParameters } = await trialClient.appParameters({}); + if (!appParameters?.chainId) throw new Error('Endpoint did not provide a valid chainId'); + + setRpcError(undefined); + setNewChainId(appParameters.chainId); + await setGrpcEndpoint(grpcEndpointInput); + // If the chain id has changed, our cache is invalid + if (appParameters.chainId !== currentChainId) + void chrome.runtime.sendMessage(ServicesMessage.ClearCache); + if (onSuccess) onSuccess(); + } catch (e: unknown) { + console.warn('Could not use new RPC endpoint', e); + setRpcError(String(e) || 'Unknown RPC failure'); + } + }; + + const chainId = newChainId ?? currentChainId; + + return { + chainId, + /** The gRPC endpoint saved to local storage. */ + grpcEndpoint, + /** + * The gRPC endpoint entered into the text field, which may or may not be + * the same as the one saved in local storage. + */ + grpcEndpointInput, + setGrpcEndpointInput, + rpcError, + setRpcError, + onSubmit, + }; +}; diff --git a/apps/extension/src/routes/popup/settings/settings-rpc.tsx b/apps/extension/src/routes/popup/settings/settings-rpc.tsx index 5d6ddb7f6a..5d5aedbb42 100644 --- a/apps/extension/src/routes/popup/settings/settings-rpc.tsx +++ b/apps/extension/src/routes/popup/settings/settings-rpc.tsx @@ -1,26 +1,14 @@ -import { QueryService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/app/v1/app_connect'; -import { createGrpcWebTransport } from '@connectrpc/connect-web'; -import { createPromiseClient } from '@connectrpc/connect'; import { FormEvent, useState } from 'react'; import { Button } from '@penumbra-zone/ui/components/ui/button'; import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; import { Input } from '@penumbra-zone/ui/components/ui/input'; -import { useChainIdQuery } from '../../../hooks/chain-id'; import { ShareGradientIcon } from '../../../icons/share-gradient'; import { SettingsHeader } from '../../../shared/components/settings-header'; -import { useStore } from '../../../state'; -import { networkSelector } from '../../../state/network'; import '@penumbra-zone/polyfills/src/Promise.withResolvers'; import { TrashIcon } from '@radix-ui/react-icons'; -import { ServicesMessage } from '@penumbra-zone/types/src/services'; +import { useGrpcEndpointForm } from '../../../hooks/use-grpc-endpoint-form'; export const SettingsRPC = () => { - const { chainId: currentChainId } = useChainIdQuery(); - const [newChainId, setNewChainId] = useState(); - const { grpcEndpoint, setGRPCEndpoint } = useStore(networkSelector); - - const [rpcInput, setRpcInput] = useState(grpcEndpoint ?? ''); - const [rpcError, setRpcError] = useState(); const [countdownTime, setCountdownTime] = useState(); const countdown = (seconds: number) => { @@ -33,32 +21,26 @@ export const SettingsRPC = () => { return promise; }; - const onSubmit = (evt: FormEvent) => { - evt.preventDefault(); - void (async () => { - try { - const trialClient = createPromiseClient( - QueryService, - createGrpcWebTransport({ baseUrl: rpcInput }), - ); - const { appParameters } = await trialClient.appParameters({}); - if (!appParameters?.chainId) throw new Error('Endpoint did not provide a valid chainId'); + const onSuccess = async () => { + // Visually confirm success for a few seconds + await countdown(5); + // Reload the extension to ensure all scopes holding the old config are killed + chrome.runtime.reload(); + }; - setRpcError(undefined); - setNewChainId(appParameters.chainId); - await setGRPCEndpoint(rpcInput); - // If the chain id has changed, our cache is invalid - if (appParameters.chainId !== currentChainId) - void chrome.runtime.sendMessage(ServicesMessage.ClearCache); - // Visually confirm success for a few seconds - await countdown(5); - // Reload the extension to ensure all scopes holding the old config are killed - chrome.runtime.reload(); - } catch (e: unknown) { - console.warn('Could not use new RPC endpoint', e); - setRpcError(String(e) || 'Unknown RPC failure'); - } - })(); + const { + onSubmit, + chainId, + grpcEndpoint, + rpcError, + setRpcError, + grpcEndpointInput, + setGrpcEndpointInput, + } = useGrpcEndpointForm(); + + const handleSubmit = (evt: FormEvent) => { + evt.preventDefault(); + void onSubmit(onSuccess); }; return ( @@ -70,7 +52,7 @@ export const SettingsRPC = () => {
@@ -80,22 +62,24 @@ export const SettingsRPC = () => {
- {rpcInput !== grpcEndpoint ? ( + {grpcEndpointInput !== grpcEndpoint ? ( ) : null}
{ setRpcError(undefined); - setRpcInput(evt.target.value); + setGrpcEndpointInput(evt.target.value); }} className='text-muted-foreground' /> @@ -104,7 +88,7 @@ export const SettingsRPC = () => {

Chain id

- {newChainId ?? currentChainId} + {chainId}
@@ -114,7 +98,7 @@ export const SettingsRPC = () => { ) : ( - - Add to this list - + {rpcError ? rpcError : chainId ? `Chain ID: ${chainId}` : null} +
); diff --git a/apps/extension/src/routes/popup/settings/settings-rpc.tsx b/apps/extension/src/routes/popup/settings/settings-rpc.tsx index 5d5aedbb42..fafb702526 100644 --- a/apps/extension/src/routes/popup/settings/settings-rpc.tsx +++ b/apps/extension/src/routes/popup/settings/settings-rpc.tsx @@ -28,15 +28,8 @@ export const SettingsRPC = () => { chrome.runtime.reload(); }; - const { - onSubmit, - chainId, - grpcEndpoint, - rpcError, - setRpcError, - grpcEndpointInput, - setGrpcEndpointInput, - } = useGrpcEndpointForm(); + const { onSubmit, chainId, grpcEndpoint, rpcError, grpcEndpointInput, setGrpcEndpointInput } = + useGrpcEndpointForm(); const handleSubmit = (evt: FormEvent) => { evt.preventDefault(); @@ -77,10 +70,7 @@ export const SettingsRPC = () => { rpcError ? 'error' : grpcEndpointInput !== grpcEndpoint ? 'warn' : 'default' } value={grpcEndpointInput} - onChange={evt => { - setRpcError(undefined); - setGrpcEndpointInput(evt.target.value); - }} + onChange={evt => setGrpcEndpointInput(evt.target.value)} className='text-muted-foreground' /> diff --git a/packages/constants/src/rpc-endpoints.ts b/packages/constants/src/grpc-endpoints.ts similarity index 85% rename from packages/constants/src/rpc-endpoints.ts rename to packages/constants/src/grpc-endpoints.ts index 6ca01e5541..5ef09a3f6b 100644 --- a/packages/constants/src/rpc-endpoints.ts +++ b/packages/constants/src/grpc-endpoints.ts @@ -1,12 +1,12 @@ import { STAKING_TOKEN_METADATA } from './assets'; -interface RpcEndpoint { +interface GrpcEndpoint { name: string; url: string; imageUrl?: string; } -export const RPC_ENDPOINTS: RpcEndpoint[] = [ +export const GRPC_ENDPOINTS: GrpcEndpoint[] = [ { name: 'Penumbra Labs Testnet RPC', url: 'https://grpc.testnet.penumbra.zone', From 9057be9cce59cfe047d8db9d03273dee6b0bb137 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 3 Apr 2024 17:26:04 -0700 Subject: [PATCH 5/9] Use the new component in the settings page --- .../src/routes/page/onboarding/routes.tsx | 6 +- .../page/onboarding/set-grpc-endpoint.tsx | 34 ++++++ .../routes/page/onboarding/set-password.tsx | 2 +- .../page/onboarding/set-rpc-endpoint.tsx | 112 ------------------ apps/extension/src/routes/page/paths.ts | 2 +- .../src/routes/popup/padding-wrapper.tsx | 9 ++ .../routes/popup/settings/settings-rpc.tsx | 80 ++----------- .../components/grpc-endpoint-form/index.tsx | 102 ++++++++++++++++ .../use-grpc-endpoint-form.ts | 10 +- 9 files changed, 167 insertions(+), 190 deletions(-) create mode 100644 apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx delete mode 100644 apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx create mode 100644 apps/extension/src/routes/popup/padding-wrapper.tsx create mode 100644 apps/extension/src/shared/components/grpc-endpoint-form/index.tsx rename apps/extension/src/{hooks => shared/components/grpc-endpoint-form}/use-grpc-endpoint-form.ts (94%) diff --git a/apps/extension/src/routes/page/onboarding/routes.tsx b/apps/extension/src/routes/page/onboarding/routes.tsx index f42ffc7f62..c68496c6d7 100644 --- a/apps/extension/src/routes/page/onboarding/routes.tsx +++ b/apps/extension/src/routes/page/onboarding/routes.tsx @@ -6,7 +6,7 @@ import { ImportSeedPhrase } from './import'; import { OnboardingSuccess } from './success'; import { SetPassword } from './set-password'; import { pageIndexLoader } from '..'; -import { SetRpcEndpoint } from './set-rpc-endpoint'; +import { SetGrpcEndpoint } from './set-grpc-endpoint'; export const onboardingRoutes = [ { @@ -30,8 +30,8 @@ export const onboardingRoutes = [ element: , }, { - path: PagePath.SET_RPC_ENDPOINT, - element: , + path: PagePath.SET_GRPC_ENDPOINT, + element: , }, { path: PagePath.ONBOARDING_SUCCESS, diff --git a/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx new file mode 100644 index 0000000000..71d78daa2a --- /dev/null +++ b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx @@ -0,0 +1,34 @@ +import { Card, CardDescription, CardHeader, CardTitle } from '@penumbra-zone/ui/components/ui/card'; +import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; +import { usePageNav } from '../../../utils/navigate'; +import { PagePath } from '../paths'; +import { ServicesMessage } from '@penumbra-zone/types/src/services'; +import { GrpcEndpointForm } from '../../../shared/components/grpc-endpoint-form'; + +export const SetGrpcEndpoint = () => { + const navigate = usePageNav(); + + const onSuccess = (): void => { + void chrome.runtime.sendMessage(ServicesMessage.OnboardComplete); + navigate(PagePath.ONBOARDING_SUCCESS); + }; + + return ( + + + + Select your preferred RPC endpoint + + The requests you make may reveal your intentions about transactions you wish to make, so + select an RPC node that you trust. If you're unsure which one to choose, leave this + option set to the default. + + + +
+ +
+
+
+ ); +}; diff --git a/apps/extension/src/routes/page/onboarding/set-password.tsx b/apps/extension/src/routes/page/onboarding/set-password.tsx index c755c17515..6f82a6f281 100644 --- a/apps/extension/src/routes/page/onboarding/set-password.tsx +++ b/apps/extension/src/routes/page/onboarding/set-password.tsx @@ -25,7 +25,7 @@ export const SetPassword = () => { void (async () => { await onboardingSave(password); - navigate(PagePath.SET_RPC_ENDPOINT); + navigate(PagePath.SET_GRPC_ENDPOINT); })(); }; diff --git a/apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx b/apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx deleted file mode 100644 index bfbd8d4310..0000000000 --- a/apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Card, CardDescription, CardHeader, CardTitle } from '@penumbra-zone/ui/components/ui/card'; -import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; -import { FormEvent, useRef } from 'react'; -import { SelectList } from '@penumbra-zone/ui/components/ui/select-list'; -import { Button } from '@penumbra-zone/ui/components/ui/button'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { ServicesMessage } from '@penumbra-zone/types/src/services'; -import { Network } from 'lucide-react'; -import { useGrpcEndpointForm } from '../../../hooks/use-grpc-endpoint-form'; -import { cn } from '@penumbra-zone/ui/lib/utils'; - -export const SetRpcEndpoint = () => { - const { - chainId, - grpcEndpoints, - grpcEndpointInput, - setGrpcEndpointInput, - onSubmit, - rpcError, - isSubmitButtonEnabled, - isCustomGrpcEndpoint, - } = useGrpcEndpointForm(); - const navigate = usePageNav(); - const customGrpcEndpointInput = useRef(null); - - const onSuccess = () => { - void chrome.runtime.sendMessage(ServicesMessage.OnboardComplete); - navigate(PagePath.ONBOARDING_SUCCESS); - }; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - if (isSubmitButtonEnabled) void onSubmit(onSuccess); - }; - - return ( - - - - Select your preferred RPC endpoint - - The requests you make may reveal your intentions about transactions you wish to make, so - select an RPC node that you trust. If you're unsure which one to choose, leave this - option set to the default. - - - -
- - {grpcEndpoints.map(option => ( - - ) - } - /> - ))} - - setGrpcEndpointInput(e.target.value)} - className='w-full bg-transparent' - /> - } - onSelect={() => { - if (!isCustomGrpcEndpoint) setGrpcEndpointInput(''); - customGrpcEndpointInput.current?.focus(); - }} - isSelected={isCustomGrpcEndpoint} - image={} - /> - - - - Add to this list - - - -
- -
- {rpcError ? rpcError : chainId ? `Chain ID: ${chainId}` : null} -
-
-
- ); -}; diff --git a/apps/extension/src/routes/page/paths.ts b/apps/extension/src/routes/page/paths.ts index b0cf73bf03..49b54b7788 100644 --- a/apps/extension/src/routes/page/paths.ts +++ b/apps/extension/src/routes/page/paths.ts @@ -6,7 +6,7 @@ export enum PagePath { IMPORT_SEED_PHRASE = '/welcome/import', ONBOARDING_SUCCESS = '/welcome/success', SET_PASSWORD = '/welcome/set-password', - SET_RPC_ENDPOINT = '/welcome/set-rpc-endpoint', + SET_GRPC_ENDPOINT = '/welcome/set-grpc-endpoint', RESTORE_PASSWORD = '/restore-password', RESTORE_PASSWORD_INDEX = '/restore-password/', RESTORE_PASSWORD_SET_PASSWORD = '/restore-password/set-password', diff --git a/apps/extension/src/routes/popup/padding-wrapper.tsx b/apps/extension/src/routes/popup/padding-wrapper.tsx new file mode 100644 index 0000000000..b4a2fea158 --- /dev/null +++ b/apps/extension/src/routes/popup/padding-wrapper.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react'; + +/** + * Wrap this around any top-level components that should have horizontal and + * bottom padding in the extension popup. + */ +export const PaddingWrapper = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; diff --git a/apps/extension/src/routes/popup/settings/settings-rpc.tsx b/apps/extension/src/routes/popup/settings/settings-rpc.tsx index fafb702526..fea7aaf227 100644 --- a/apps/extension/src/routes/popup/settings/settings-rpc.tsx +++ b/apps/extension/src/routes/popup/settings/settings-rpc.tsx @@ -1,15 +1,15 @@ -import { FormEvent, useState } from 'react'; -import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { useState } from 'react'; import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; -import { Input } from '@penumbra-zone/ui/components/ui/input'; import { ShareGradientIcon } from '../../../icons/share-gradient'; import { SettingsHeader } from '../../../shared/components/settings-header'; import '@penumbra-zone/polyfills/src/Promise.withResolvers'; -import { TrashIcon } from '@radix-ui/react-icons'; -import { useGrpcEndpointForm } from '../../../hooks/use-grpc-endpoint-form'; +import { GrpcEndpointForm } from '../../../shared/components/grpc-endpoint-form'; +import { PaddingWrapper } from '../padding-wrapper'; export const SettingsRPC = () => { const [countdownTime, setCountdownTime] = useState(); + const submitButtonLabel = + Number(countdownTime) > 0 ? `Saved! Restarting in ${countdownTime}...` : 'Save'; const countdown = (seconds: number) => { const { promise, resolve } = Promise.withResolvers(); @@ -22,20 +22,12 @@ export const SettingsRPC = () => { }; const onSuccess = async () => { - // Visually confirm success for a few seconds + // Visually confirm success for a few seconds, then reload the extension to + // ensure all scopes holding the old config are killed await countdown(5); - // Reload the extension to ensure all scopes holding the old config are killed chrome.runtime.reload(); }; - const { onSubmit, chainId, grpcEndpoint, rpcError, grpcEndpointInput, setGrpcEndpointInput } = - useGrpcEndpointForm(); - - const handleSubmit = (evt: FormEvent) => { - evt.preventDefault(); - void onSubmit(onSuccess); - }; - return (
@@ -43,60 +35,10 @@ export const SettingsRPC = () => {
-
-
-
-
-
RPC URL
- {rpcError ?
{rpcError}
: null} -
-
-
- {grpcEndpointInput !== grpcEndpoint ? ( - - ) : null} -
- setGrpcEndpointInput(evt.target.value)} - className='text-muted-foreground' - /> -
-
-
-

Chain id

-
- {chainId} -
-
-
- {countdownTime !== undefined ? ( - - ) : ( - - )} -
+ + + +
); diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx new file mode 100644 index 0000000000..b33c499c5e --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx @@ -0,0 +1,102 @@ +import { FormEvent, useRef } from 'react'; +import { SelectList } from '@penumbra-zone/ui/components/ui/select-list'; +import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { Network } from 'lucide-react'; +import { useGrpcEndpointForm } from './use-grpc-endpoint-form'; +import { cn } from '@penumbra-zone/ui/lib/utils'; + +/** + * Renders all the parts of the gRPC endpoint form that are shared between the + * onboarding flow and the RPC settings page. + */ +export const GrpcEndpointForm = ({ + submitButtonLabel, + onSuccess, +}: { + submitButtonLabel: string; + onSuccess: () => void | Promise; +}) => { + const { + chainId, + grpcEndpoints, + grpcEndpointInput, + setGrpcEndpointInput, + onSubmit, + rpcError, + isSubmitButtonEnabled, + isCustomGrpcEndpoint, + } = useGrpcEndpointForm(); + const customGrpcEndpointInput = useRef(null); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (isSubmitButtonEnabled) void onSubmit(onSuccess); + }; + + return ( +
+
+ + {grpcEndpoints.map(option => ( + + ) + } + /> + ))} + + setGrpcEndpointInput(e.target.value)} + className='w-full bg-transparent' + /> + } + onSelect={() => { + if (!isCustomGrpcEndpoint) setGrpcEndpointInput(''); + customGrpcEndpointInput.current?.focus(); + }} + isSelected={isCustomGrpcEndpoint} + image={} + /> + + + + Add to this list + + + +
+ + {(!!rpcError || !!chainId) && ( +
+ {rpcError ? rpcError : chainId ? `Chain ID: ${chainId}` : null} +
+ )} +
+ ); +}; diff --git a/apps/extension/src/hooks/use-grpc-endpoint-form.ts b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts similarity index 94% rename from apps/extension/src/hooks/use-grpc-endpoint-form.ts rename to apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts index 6a6fdcc3a9..3cb8d11ef5 100644 --- a/apps/extension/src/hooks/use-grpc-endpoint-form.ts +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -2,8 +2,8 @@ import { Code, ConnectError, createPromiseClient } from '@connectrpc/connect'; import { QueryService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/app/v1/app_connect'; import { createGrpcWebTransport } from '@connectrpc/connect-web'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { AllSlices } from '../state'; -import { useStoreShallow } from '../utils/use-store-shallow'; +import { AllSlices } from '../../../state'; +import { useStoreShallow } from '../../../utils/use-store-shallow'; import { ServicesMessage } from '@penumbra-zone/types/src/services'; import { GRPC_ENDPOINTS } from '@penumbra-zone/constants/src/grpc-endpoints'; import { debounce } from 'lodash'; @@ -91,12 +91,14 @@ export const useGrpcEndpointForm = () => { const onSubmit = async ( /** Callback to run when the RPC endpoint successfully saves */ - onSuccess?: () => unknown, + onSuccess: () => void | Promise, ) => { + setIsSubmitButtonEnabled(false); await setGrpcEndpoint(grpcEndpointInput); // If the chain id has changed, our cache is invalid if (originalChainId !== chainId) void chrome.runtime.sendMessage(ServicesMessage.ClearCache); - if (onSuccess) onSuccess(); + await onSuccess(); + setIsSubmitButtonEnabled(true); }; return { From 6732f66cdb34f8364ab81903432a3cab4f653ab7 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 3 Apr 2024 17:54:34 -0700 Subject: [PATCH 6/9] Remove unnecessary comment --- .../components/grpc-endpoint-form/use-grpc-endpoint-form.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts index 3cb8d11ef5..8a7697c3ee 100644 --- a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -24,9 +24,6 @@ const isValidUrl = (url: string) => { } }; -/** - * Provides everything needed for a gRPC endpoint picking form. - */ export const useGrpcEndpointForm = () => { const [originalChainId, setOriginalChainId] = useState(); const [chainId, setChainId] = useState(); From da87b586c4a6a2a2131788ea70416078e5fb5c1a Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Thu, 4 Apr 2024 10:33:58 -0700 Subject: [PATCH 7/9] Remove testnet preview from list --- packages/constants/src/grpc-endpoints.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/constants/src/grpc-endpoints.ts b/packages/constants/src/grpc-endpoints.ts index 5ef09a3f6b..d6923db2ee 100644 --- a/packages/constants/src/grpc-endpoints.ts +++ b/packages/constants/src/grpc-endpoints.ts @@ -12,9 +12,4 @@ export const GRPC_ENDPOINTS: GrpcEndpoint[] = [ url: 'https://grpc.testnet.penumbra.zone', imageUrl: STAKING_TOKEN_METADATA.images[0]?.svg, }, - { - name: 'Penumbra Labs Testnet Preview RPC', - url: 'https://grpc.testnet-preview.penumbra.zone', - imageUrl: STAKING_TOKEN_METADATA.images[0]?.svg, - }, ]; From 8ef1d3e35c96b5599724dbd996d592e72949dbdd Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Thu, 4 Apr 2024 10:53:15 -0700 Subject: [PATCH 8/9] Fix debounce import --- .../components/grpc-endpoint-form/use-grpc-endpoint-form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts index 8a7697c3ee..ae0ac14d9d 100644 --- a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -6,7 +6,7 @@ import { AllSlices } from '../../../state'; import { useStoreShallow } from '../../../utils/use-store-shallow'; import { ServicesMessage } from '@penumbra-zone/types/src/services'; import { GRPC_ENDPOINTS } from '@penumbra-zone/constants/src/grpc-endpoints'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; const randomSort = () => (Math.random() >= 0.5 ? 1 : -1); From 4251dd970f81c78a9e347f51d1519f867b1e6954 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Thu, 4 Apr 2024 13:21:54 -0700 Subject: [PATCH 9/9] Confirm changing the chain ID --- .../grpc-endpoint-form/chain-id-or-error.tsx | 25 ++++ .../confirm-changed-chain-id-dialog.tsx | 46 +++++++ .../components/grpc-endpoint-form/index.tsx | 119 +++++++++--------- .../use-grpc-endpoint-form.ts | 44 ++++++- packages/ui/components/ui/dialog.tsx | 21 ++++ 5 files changed, 193 insertions(+), 62 deletions(-) create mode 100644 apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx create mode 100644 apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx new file mode 100644 index 0000000000..4c9bf61d77 --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx @@ -0,0 +1,25 @@ +import { cn } from '@penumbra-zone/ui/lib/utils'; + +export const ChainIdOrError = ({ + error, + chainId, + chainIdChanged, +}: { + error?: string; + chainId?: string; + chainIdChanged: boolean; +}) => { + if (!error && !chainId) return null; + + return ( +
+ {error ? error : chainId ? `Chain ID: ${chainId}` : null} +
+ ); +}; diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx new file mode 100644 index 0000000000..3d3e7a9c20 --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx @@ -0,0 +1,46 @@ +import { PromiseWithResolvers } from '@penumbra-zone/polyfills/src/Promise.withResolvers'; +import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, +} from '@penumbra-zone/ui/components/ui/dialog'; + +export const ConfirmChangedChainIdDialog = ({ + chainId, + originalChainId, + promiseWithResolvers, +}: { + chainId?: string; + originalChainId?: string; + promiseWithResolvers?: PromiseWithResolvers; +}) => { + return ( + + + Chain ID changed + +
+

+ The originally selected gRPC endpoint was serving chain ID{' '} + {originalChainId}. But the gRPC endpoint + you've selected now serves chain ID{' '} + {chainId}. Was this intentional? +

+ +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx index b33c499c5e..9c8d0344a1 100644 --- a/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx +++ b/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx @@ -3,7 +3,8 @@ import { SelectList } from '@penumbra-zone/ui/components/ui/select-list'; import { Button } from '@penumbra-zone/ui/components/ui/button'; import { Network } from 'lucide-react'; import { useGrpcEndpointForm } from './use-grpc-endpoint-form'; -import { cn } from '@penumbra-zone/ui/lib/utils'; +import { ConfirmChangedChainIdDialog } from './confirm-changed-chain-id-dialog'; +import { ChainIdOrError } from './chain-id-or-error'; /** * Renders all the parts of the gRPC endpoint form that are shared between the @@ -18,6 +19,9 @@ export const GrpcEndpointForm = ({ }) => { const { chainId, + chainIdChanged, + confirmChangedChainIdPromise, + originalChainId, grpcEndpoints, grpcEndpointInput, setGrpcEndpointInput, @@ -34,69 +38,68 @@ export const GrpcEndpointForm = ({ }; return ( -
-
- - {grpcEndpoints.map(option => ( + <> +
+ + + {grpcEndpoints.map(option => ( + + ) + } + /> + ))} + - ) + label='Custom RPC' + secondary={ + setGrpcEndpointInput(e.target.value)} + className='w-full bg-transparent' + /> } + onSelect={() => { + if (!isCustomGrpcEndpoint) setGrpcEndpointInput(''); + customGrpcEndpointInput.current?.focus(); + }} + isSelected={isCustomGrpcEndpoint} + image={} /> - ))} + - setGrpcEndpointInput(e.target.value)} - className='w-full bg-transparent' - /> - } - onSelect={() => { - if (!isCustomGrpcEndpoint) setGrpcEndpointInput(''); - customGrpcEndpointInput.current?.focus(); - }} - isSelected={isCustomGrpcEndpoint} - image={} - /> - + + Add to this list + - - Add to this list - + + - - + +
- {(!!rpcError || !!chainId) && ( -
- {rpcError ? rpcError : chainId ? `Chain ID: ${chainId}` : null} -
- )} -
+ + ); }; diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts index ae0ac14d9d..595017169c 100644 --- a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -7,6 +7,7 @@ import { useStoreShallow } from '../../../utils/use-store-shallow'; import { ServicesMessage } from '@penumbra-zone/types/src/services'; import { GRPC_ENDPOINTS } from '@penumbra-zone/constants/src/grpc-endpoints'; import debounce from 'lodash/debounce'; +import { PromiseWithResolvers } from '@penumbra-zone/polyfills/src/Promise.withResolvers'; const randomSort = () => (Math.random() >= 0.5 ? 1 : -1); @@ -34,6 +35,10 @@ export const useGrpcEndpointForm = () => { ); const [rpcError, setRpcError] = useState(); const [isSubmitButtonEnabled, setIsSubmitButtonEnabled] = useState(false); + const [confirmChangedChainIdPromise, setConfirmChangedChainIdPromise] = useState< + PromiseWithResolvers | undefined + >(); + const isCustomGrpcEndpoint = !GRPC_ENDPOINTS.some(({ url }) => url === grpcEndpointInput); const setGrpcEndpointInputOnLoadFromState = useCallback(() => { @@ -86,22 +91,53 @@ export const useGrpcEndpointForm = () => { [handleChangeGrpcEndpointInput, grpcEndpointInput], ); + const chainIdChanged = !!originalChainId && !!chainId && originalChainId !== chainId; + const onSubmit = async ( /** Callback to run when the RPC endpoint successfully saves */ onSuccess: () => void | Promise, ) => { setIsSubmitButtonEnabled(false); - await setGrpcEndpoint(grpcEndpointInput); + // If the chain id has changed, our cache is invalid - if (originalChainId !== chainId) void chrome.runtime.sendMessage(ServicesMessage.ClearCache); + if (chainIdChanged) { + const promiseWithResolvers = Promise.withResolvers(); + setConfirmChangedChainIdPromise(promiseWithResolvers); + + try { + await promiseWithResolvers.promise; + } catch { + setIsSubmitButtonEnabled(true); + return; + } finally { + setConfirmChangedChainIdPromise(undefined); + } + + await setGrpcEndpoint(grpcEndpointInput); + void chrome.runtime.sendMessage(ServicesMessage.ClearCache); + } else { + await setGrpcEndpoint(grpcEndpointInput); + } + await onSuccess(); setIsSubmitButtonEnabled(true); + setOriginalChainId(chainId); }; return { chainId, - /** The gRPC endpoint saved to local storage. */ - grpcEndpoint, + originalChainId, + /** + * gRPC endpoints report which chain they represent via the `chainId` + * property returned by their `appParameters` RPC method. All endpoints for + * a given chain will have the same chain ID. If the chain ID changes when a + * user selects a different gRPC endpoint, that means that the new gRPC + * endpoint represents an entirely different chain than the user was + * previously using. This is significant, and should be surfaced to the + * user. + */ + chainIdChanged, + confirmChangedChainIdPromise, /** * The gRPC endpoint entered into the text field, which may or may not be * the same as the one saved in local storage. diff --git a/packages/ui/components/ui/dialog.tsx b/packages/ui/components/ui/dialog.tsx index 41ae0c6b8f..2a0f68730e 100644 --- a/packages/ui/components/ui/dialog.tsx +++ b/packages/ui/components/ui/dialog.tsx @@ -7,6 +7,10 @@ import { cn } from '../../lib/utils'; import { cva, VariantProps } from 'class-variance-authority'; /** + * You can use a `` in two ways. + * + * 1) Letting it manage its own internal open vs. closed state. + * * @example * ```tsx * @@ -25,6 +29,23 @@ import { cva, VariantProps } from 'class-variance-authority'; * * + * + * + * Header here, which includes a built-in close button. + * + *

Content here

+ * + *
Clicking anything inside here will close the dialog.
+ *
+ *
+ *