Keep iframe modal in separate component

This commit is contained in:
Isha 2024-11-08 12:26:04 +05:30 committed by IshaVenikar
parent 51d7dd7c57
commit 97fa1988fb
8 changed files with 191 additions and 389 deletions

View File

@ -15,3 +15,4 @@ VITE_PASSKEY_WALLET_RPID=
VITE_TURNKEY_API_BASE_URL=
VITE_LACONICD_CHAIN_ID=
VITE_IFRAME_ORIGIN_URL=

View File

@ -0,0 +1,49 @@
import { Select, Option } from '@snowballtools/material-tailwind-react-fork';
const AccountsDropdown = ({
accounts,
onAccountChange,
}: {
accounts: string[];
onAccountChange: (selectedAccount: string) => void;
}) => {
return (
<div className="p-6 bg-slate-100 dark:bg-overlay3 rounded-lg mb-6 shadow-md">
{!accounts.length ? (
<div className="text-center">
<p className="text-gray-700 dark:text-gray-300 mb-4">
No accounts found. Please visit{' '}
<a
href="https://store.laconic.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline dark:text-blue-400"
>
store.laconic.com
</a>{' '}
to create a wallet.
</p>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0]}
onChange={(value) => value && onAccountChange(value)}
className="dark:bg-overlay2 dark:text-foreground"
aria-label="Wallet Account Selector"
>
{accounts.map((account, index) => (
<Option key={index} value={account}>
{account}
</Option>
))}
</Select>
</div>
)}
</div>
);
};
export default AccountsDropdown;

View File

@ -20,10 +20,11 @@ import { Button } from '../../shared/Button';
import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext';
import IFrame from './IFrame';
import IFrameModal from './IFrameModal';
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
import { EnvironmentVariablesFormValues } from 'types/types';
import { VITE_LACONICD_CHAIN_ID } from 'utils/constants';
import { VITE_LACONICD_CHAIN_ID, VITE_IFRAME_ORIGIN_URL } from 'utils/constants';
import AccountsDropdown from './AccountsDropdown';
type ConfigureDeploymentFormValues = {
option: string;
@ -36,6 +37,7 @@ type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000';
const TX_APPROVAL_TIMEOUT = 60000;
const Configure = () => {
const [isLoading, setIsLoading] = useState(false);
@ -175,7 +177,6 @@ const Configure = () => {
const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => {
try {
setIsFrameVisible(true);
const deployerLrn = createFormData.lrn;
const deployer = deployers.find(
(deployer) => deployer.deployerLrn === deployerLrn,
@ -213,6 +214,33 @@ const Configure = () => {
senderAddress,
amountToBePaid,
);
if (!txHash) {
return;
}
// Validate transaction hash
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amount,
);
if (isTxHashValid) {
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
} else {
toast({
id: 'invalid-tx-hash',
title: 'Transaction validation failed',
variant: 'error',
onDismiss: dismiss,
});
}
}
const environmentVariables = createFormData.variables.map(
@ -300,48 +328,16 @@ const Configure = () => {
setIsPaymentDone(false);
setIsPaymentLoading(true);
toast({
id: 'sending-payment-request',
title: 'Check and approve payment request',
variant: 'loading',
onDismiss: dismiss,
});
await requestTx(senderAddress, snowballAddress, amount);
const txHash = await new Promise<string>((resolve, reject) => {
const handleTxStatus = async (event: MessageEvent) => {
if (event.origin !== 'http://localhost:3001') return;
if (event.origin !== VITE_IFRAME_ORIGIN_URL) return;
if (event.data.type === 'TRANSACTION_SUCCESS') {
const txResponse = event.data.data;
resolve(txResponse);
setIsFrameVisible(false);
// Validate transaction hash
const isTxHashValid = await verifyTx(
senderAddress,
txResponse,
amount,
);
if (isTxHashValid) {
resolve(txResponse);
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
} else {
reject(new Error('Invalid transaction hash'));
toast({
id: 'invalid-tx-hash',
title: 'Transaction validation failed',
variant: 'error',
onDismiss: dismiss,
});
}
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
reject(new Error('Transaction failed'));
@ -362,7 +358,13 @@ const Configure = () => {
setTimeout(() => {
reject(new Error('Transaction timeout'));
window.removeEventListener('message', handleTxStatus);
}, 60000);
toast({
id: 'transaction-timeout',
title: 'The transaction request timed out. Please try again',
variant: 'error',
onDismiss: dismiss,
});
}, TX_APPROVAL_TIMEOUT);
});
return txHash;
} catch (error) {
@ -391,8 +393,10 @@ const Configure = () => {
toAddress: recipient,
amount,
},
'http://localhost:3001'
VITE_IFRAME_ORIGIN_URL
);
setIsFrameVisible(true);
};
useEffect(() => {
@ -561,10 +565,9 @@ const Configure = () => {
</div>
) : (
<>
<IFrame
accounts={accounts}
<AccountsDropdown accounts={accounts} onAccountChange={onAccountChange} />
<IFrameModal
setAccounts={setAccounts}
onAccountChange={onAccountChange}
isVisible={isFrameVisible}
toggleModal={toggleModal}
/>

View File

@ -1,124 +0,0 @@
import { useCallback, useEffect } from 'react';
import { Select, Option } from '@snowballtools/material-tailwind-react-fork';
import { Box, Modal } from '@mui/material';
import { VITE_LACONICD_CHAIN_ID } from 'utils/constants';
const IFrame = ({
accounts,
setAccounts,
onAccountChange,
isVisible,
toggleModal,
}: {
accounts: string[];
setAccounts: (accounts: string[]) => void;
onAccountChange: (selectedAccount: string) => void;
isVisible: boolean;
toggleModal: () => void;
}) => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// TODO: Use env for origin URL
if (event.origin !== 'http://localhost:3001') return;
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
setAccounts(event.data.data);
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
const getDataFromWallet = useCallback(() => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement;
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded');
return;
}
iframe.contentWindow.postMessage(
{
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: VITE_LACONICD_CHAIN_ID,
},
'http://localhost:3001'
);
}, []);
return (
<div className="p-6 bg-slate-100 dark:bg-overlay3 rounded-lg mb-6 shadow-md">
{!accounts.length ? (
<div className="text-center">
<p className="text-gray-700 dark:text-gray-300 mb-4">
No accounts found. Please visit{' '}
<a
href="https://store.laconic.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline dark:text-blue-400"
>
store.laconic.com
</a>{' '}
to create a wallet.
</p>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0]}
onChange={(value) => value && onAccountChange(value)}
className="dark:bg-overlay2 dark:text-foreground"
aria-label="Wallet Account Selector"
>
{accounts.map((account, index) => (
<Option key={index} value={account}>
{account}
</Option>
))}
</Select>
</div>
)}
<Modal open={isVisible} onClose={toggleModal} keepMounted>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90%',
maxWidth: '1200px',
height: '600px',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: 24,
borderRadius: '8px',
outline: 'none',
bgcolor: 'background.paper',
}}
>
<iframe
onLoad={getDataFromWallet}
id="walletIframe"
src="http://localhost:3001/WalletEmbed"
width="100%"
height="100%"
sandbox="allow-scripts allow-same-origin"
className="border rounded-md shadow-sm"
></iframe>
</Box>
</Modal>
</div>
);
};
export default IFrame;

View File

@ -0,0 +1,85 @@
import { useCallback, useEffect } from 'react';
import { Box, Modal } from '@mui/material';
import { VITE_LACONICD_CHAIN_ID, VITE_IFRAME_ORIGIN_URL } from 'utils/constants';
const IFrameModal = ({
setAccounts,
isVisible,
toggleModal,
}: {
setAccounts: (accounts: string[]) => void;
isVisible: boolean;
toggleModal: () => void;
}) => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// TODO: Use env for origin URL
if (event.origin !== VITE_IFRAME_ORIGIN_URL) return;
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
setAccounts(event.data.data);
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
const getDataFromWallet = useCallback(() => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement;
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded');
return;
}
iframe.contentWindow.postMessage(
{
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: VITE_LACONICD_CHAIN_ID,
},
VITE_IFRAME_ORIGIN_URL
);
}, []);
return (
<Modal open={isVisible} onClose={toggleModal} keepMounted>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90%',
maxWidth: '1200px',
height: '600px',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: 24,
borderRadius: '8px',
outline: 'none',
bgcolor: 'background.paper',
}}
>
<iframe
onLoad={getDataFromWallet}
id="walletIframe"
src={`${VITE_IFRAME_ORIGIN_URL}/WalletEmbed`}
width="100%"
height="100%"
sandbox="allow-scripts allow-same-origin"
className="border rounded-md shadow-sm"
></iframe>
</Box>
</Modal>
)
};
export default IFrameModal;

View File

@ -1,210 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import SignClient from '@walletconnect/sign-client';
import { getSdkError } from '@walletconnect/utils';
import { SessionTypes } from '@walletconnect/types';
import { walletConnectModal } from '../utils/web3modal';
import {
VITE_LACONICD_CHAIN_ID,
VITE_WALLET_CONNECT_ID,
} from 'utils/constants';
interface ClientInterface {
signClient: SignClient | undefined;
session: SessionTypes.Struct | undefined;
loadingSession: boolean;
onConnect: () => Promise<void>;
onDisconnect: () => Promise<void>;
onSessionDelete: () => void;
accounts: { address: string }[];
}
const ClientContext = createContext({} as ClientInterface);
export const useWalletConnectClient = () => {
return useContext(ClientContext);
};
export const WalletConnectClientProvider = ({
children,
}: {
children: JSX.Element;
}) => {
const [signClient, setSignClient] = useState<SignClient>();
const [session, setSession] = useState<SessionTypes.Struct>();
const [loadingSession, setLoadingSession] = useState(true);
const [accounts, setAccounts] = useState<{ address: string }[]>([]);
const isSignClientInitializing = useRef<boolean>(false);
const onSessionConnect = useCallback(async (session: SessionTypes.Struct) => {
setSession(session);
}, []);
const subscribeToEvents = useCallback(
async (client: SignClient) => {
client.on('session_update', ({ topic, params }) => {
const { namespaces } = params;
const currentSession = client.session.get(topic);
const updatedSession = { ...currentSession, namespaces };
setSession(updatedSession);
});
},
[setSession],
);
const onConnect = async () => {
const proposalNamespace = {
cosmos: {
methods: ['cosmos_sendTokens'],
chains: [`cosmos:${VITE_LACONICD_CHAIN_ID}`],
events: [],
},
};
try {
const { uri, approval } = await signClient!.connect({
requiredNamespaces: proposalNamespace,
});
if (uri) {
walletConnectModal.openModal({ uri });
const session = await approval();
onSessionConnect(session);
walletConnectModal.closeModal();
}
} catch (e) {
console.error(e);
}
};
const onDisconnect = useCallback(async () => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session === 'undefined') {
throw new Error('Session is not connected');
}
await signClient.disconnect({
topic: session.topic,
reason: getSdkError('USER_DISCONNECTED'),
});
onSessionDelete();
}, [signClient, session]);
const onSessionDelete = () => {
setAccounts([]);
setSession(undefined);
};
const checkPersistedState = useCallback(
async (signClient: SignClient) => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session !== 'undefined') return;
if (signClient.session.length) {
const lastKeyIndex = signClient.session.keys.length - 1;
const previousSsession = signClient.session.get(
signClient.session.keys[lastKeyIndex],
);
await onSessionConnect(previousSsession);
return previousSsession;
}
},
[session, onSessionConnect],
);
const createClient = useCallback(async () => {
isSignClientInitializing.current = true;
try {
const signClient = await SignClient.init({
projectId: VITE_WALLET_CONNECT_ID,
metadata: {
name: 'Deploy App',
description: '',
url: window.location.href,
icons: ['https://avatars.githubusercontent.com/u/92608123'],
},
});
setSignClient(signClient);
await checkPersistedState(signClient);
await subscribeToEvents(signClient);
setLoadingSession(false);
} catch (e) {
console.error('error in createClient', e);
}
isSignClientInitializing.current = false;
}, [setSignClient, checkPersistedState, subscribeToEvents]);
useEffect(() => {
if (!signClient && !isSignClientInitializing.current) {
createClient();
}
}, [signClient, createClient]);
useEffect(() => {
const populateAccounts = async () => {
if (!session) {
return;
}
if (!session.namespaces['cosmos']) {
console.log('Accounts for cosmos namespace not found');
return;
}
const cosmosAddresses = session.namespaces['cosmos'].accounts;
const cosmosAccounts = cosmosAddresses.map((address) => ({
address,
}));
const allAccounts = cosmosAccounts;
setAccounts(allAccounts);
};
populateAccounts();
}, [session]);
useEffect(() => {
if (!signClient) {
return;
}
signClient.on('session_delete', onSessionDelete);
return () => {
signClient.off('session_delete', onSessionDelete);
};
});
return (
<ClientContext.Provider
value={{
signClient,
onConnect,
onDisconnect,
onSessionDelete,
loadingSession,
session,
accounts,
}}
>
{children}
</ClientContext.Provider>
);
};

View File

@ -4,10 +4,9 @@ import assert from 'assert';
import { GQLClient } from 'gql-client';
import { ThemeProvider } from '@snowballtools/material-tailwind-react-fork';
import './index.css';
import '@fontsource/inter';
import '@fontsource-variable/jetbrains-mono';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { GQLClientProvider } from './context/GQLClientContext';
@ -16,7 +15,7 @@ import { Toaster } from 'components/shared/Toast';
import { LogErrorBoundary } from 'utils/log-error';
import { BASE_URL } from 'utils/constants';
import Web3ModalProvider from './context/Web3Provider';
import { WalletConnectClientProvider } from 'context/WalletConnectContext';
import './index.css';
console.log(`v-0.0.9`);
@ -32,16 +31,14 @@ const gqlClient = new GQLClient({ gqlEndpoint });
root.render(
<LogErrorBoundary>
<React.StrictMode>
<WalletConnectClientProvider>
<ThemeProvider>
<Web3ModalProvider>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster />
</GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider>
</WalletConnectClientProvider>
<ThemeProvider>
<Web3ModalProvider>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster />
</GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider>
</React.StrictMode>
</LogErrorBoundary>,
);

View File

@ -12,3 +12,4 @@ export const VITE_WALLET_CONNECT_ID = import.meta.env.VITE_WALLET_CONNECT_ID;
export const VITE_BUGSNAG_API_KEY = import.meta.env.VITE_BUGSNAG_API_KEY;
export const VITE_LIT_RELAY_API_KEY = import.meta.env.VITE_LIT_RELAY_API_KEY;
export const VITE_LACONICD_CHAIN_ID = import.meta.env.VITE_LACONICD_CHAIN_ID;
export const VITE_IFRAME_ORIGIN_URL = import.meta.env.VITE_IFRAME_ORIGIN_URL;