Skip to content
This repository was archived by the owner on Jan 22, 2025. It is now read-only.

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
An example React app that exercises the new @solana/web3.js and the…
Browse files Browse the repository at this point in the history
… new Wallet Standard wallet adapter in `@solana/react`
steveluscher committed Jun 14, 2024
1 parent 1b73e0f commit ca58fa4
Showing 37 changed files with 4,346 additions and 851 deletions.
10 changes: 10 additions & 0 deletions examples/react-app/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: ['../../.eslintrc.js', '@solana/eslint-config-solana/react'],
ignorePatterns: ['dist', '.eslintrc.cjs'],
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
};
24 changes: 24 additions & 0 deletions examples/react-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
20 changes: 20 additions & 0 deletions examples/react-app/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Copyright (c) 2023 Solana Labs, Inc

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1 change: 1 addition & 0 deletions examples/react-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @solana/react-example-app
13 changes: 13 additions & 0 deletions examples/react-app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/solanaLogoMark.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solana React Example App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
39 changes: 39 additions & 0 deletions examples/react-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@solana/example-react-appz",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^3.0.5",
"@solana-program/system": "^0.3.2",
"@solana/react": "workspace:*",
"@solana/web3.js": "workspace:@solana/web3.js-experimental@*",
"@wallet-standard/core": "pre",
"@wallet-standard/react": "pre",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-error-boundary": "^4.0.13",
"swr": "^2.2.5"
},
"devDependencies": {
"@solana/wallet-standard-features": "^1.2.0",
"@types/react": "^18.3",
"@types/react-dom": "^18.3",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}
13 changes: 13 additions & 0 deletions examples/react-app/public/solanaLogoMark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 78 additions & 0 deletions examples/react-app/src/components/Balance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Text } from '@radix-ui/themes';
import { AccountNotificationsApi, Address, address, GetBalanceApi, Rpc, RpcSubscriptions } from '@solana/web3.js';
import type { UiWalletAccount } from '@wallet-standard/react';
import { useCallback, useContext } from 'react';
import useSWRSubscription, { SWRSubscription } from 'swr/subscription';

import { ChainContext } from '../context/ChainContext';
import { RpcContext } from '../context/RpcContext';

type Props = Readonly<{
account: UiWalletAccount;
}>;

function balanceSubscribe(
rpc: Rpc<GetBalanceApi>,
rpcSubscriptions: RpcSubscriptions<AccountNotificationsApi>,
{ address }: { address: Address },
{ next }: { next(error: unknown, value?: bigint): void },
) {
const abortController = new AbortController();
let lastUpdateSlot = 0n;
// Fetch the current balance of this account.
rpc.getBalance(address, { commitment: 'confirmed' })
.send({ abortSignal: abortController.signal })
.then(({ context: { slot }, value: lamports }) => {
if (slot < lastUpdateSlot) {
// This implies that we already received an update newer than this one (ie. from the
// subscription below).
return;
}
lastUpdateSlot = slot;
next(null, lamports);
})
.catch(e => next(e));
// Subscribe for updates to that balance.
rpcSubscriptions
.accountNotifications(address)
.subscribe({ abortSignal: abortController.signal })
.then(async accountInfoNotifications => {
try {
for await (const {
context: { slot },
value,
} of accountInfoNotifications) {
if (slot < lastUpdateSlot) {
// This implies that we already received an update newer than this one (ie.
// from the fetch above).
continue;
}
lastUpdateSlot = slot;
next(null, value.lamports);
}
} catch (e) {
next(e);
}
})
.catch(e => next(e));
return () => abortController.abort();
}

export function Balance({ account }: Props) {
const { chain } = useContext(ChainContext);
const { rpc, rpcSubscriptions } = useContext(RpcContext);
const subscribe = useCallback<SWRSubscription<{ address: Address }, bigint>>(
(...args) => balanceSubscribe(rpc, rpcSubscriptions, ...args),
[rpc, rpcSubscriptions],
);
const { data: lamports } = useSWRSubscription({ address: address(account.address), chain }, subscribe);
if (lamports == null) {
return <Text>&ndash;</Text>;
} else {
const formattedSolValue = new Intl.NumberFormat(undefined, { maximumFractionDigits: 5 }).format(
// @ts-expect-error This format string is 100% allowed now.
`${lamports}E-9`,
);
return <Text>{`${formattedSolValue} \u25CE`}</Text>;
}
}
100 changes: 100 additions & 0 deletions examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Pencil1Icon } from '@radix-ui/react-icons';
import { Blockquote, Box, Button, Code, DataList, Dialog, Flex, TextField } from '@radix-ui/themes';
import { getBase64Decoder } from '@solana/web3.js';
import type { ReadonlyUint8Array } from '@wallet-standard/core';
import type { SyntheticEvent } from 'react';
import { useRef, useState } from 'react';

import { ErrorDialog } from '../components/ErrorDialog';

type Props = Readonly<{
signMessage(message: ReadonlyUint8Array): Promise<ReadonlyUint8Array>;
}>;

export function BaseSignMessageFeaturePanel({ signMessage }: Props) {
const { current: NO_ERROR } = useRef(Symbol());
const [isSigningMessage, setIsSigningMessage] = useState(false);
const [error, setError] = useState<unknown | typeof NO_ERROR>(NO_ERROR);
const [lastSignature, setLastSignature] = useState<ReadonlyUint8Array | undefined>();
const [text, setText] = useState<string>();
return (
<Flex asChild gap="2" direction={{ initial: 'column', sm: 'row' }} style={{ width: '100%' }}>
<form
onSubmit={async e => {
e.preventDefault();
setError(NO_ERROR);
setIsSigningMessage(true);
try {
const signature = await signMessage(new TextEncoder().encode(text));
setLastSignature(signature);
} catch (e) {
setLastSignature(undefined);
setError(e);
} finally {
setIsSigningMessage(false);
}
}}
>
<Box flexGrow="1">
<TextField.Root
placeholder="Write a message to sign"
onChange={(e: SyntheticEvent<HTMLInputElement>) => setText(e.currentTarget.value)}
value={text}
>
<TextField.Slot>
<Pencil1Icon />
</TextField.Slot>
</TextField.Root>
</Box>
<Dialog.Root
open={!!lastSignature}
onOpenChange={open => {
if (!open) {
setLastSignature(undefined);
}
}}
>
<Dialog.Trigger>
<Button
color={error ? undefined : 'red'}
disabled={!text}
loading={isSigningMessage}
type="submit"
>
Sign Message
</Button>
</Dialog.Trigger>
{lastSignature ? (
<Dialog.Content
onClick={e => {
e.stopPropagation();
}}
>
<Dialog.Title>You Signed a Message!</Dialog.Title>
<DataList.Root orientation={{ initial: 'vertical', sm: 'horizontal' }}>
<DataList.Item>
<DataList.Label minWidth="88px">Message</DataList.Label>
<DataList.Value>
<Blockquote>{text}</Blockquote>
</DataList.Value>
</DataList.Item>
<DataList.Item>
<DataList.Label minWidth="88px">Signature</DataList.Label>
<DataList.Value>
<Code truncate>{getBase64Decoder().decode(lastSignature)}</Code>
</DataList.Value>
</DataList.Item>
</DataList.Root>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button>Cool!</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
) : null}
</Dialog.Root>
{error !== NO_ERROR ? <ErrorDialog error={error} onClose={() => setError(NO_ERROR)} /> : null}
</form>
</Flex>
);
}
101 changes: 101 additions & 0 deletions examples/react-app/src/components/ConnectWalletMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Button, Callout, DropdownMenu } from '@radix-ui/themes';
import { StandardConnect, StandardDisconnect } from '@wallet-standard/core';
import type { UiWallet } from '@wallet-standard/react';
import { uiWalletAccountBelongsToUiWallet, useWallets } from '@wallet-standard/react';
import { useContext, useRef, useState, useTransition } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import { SelectedWalletAccountContext } from '../context/SelectedWalletAccountContext';
import { ConnectWalletMenuItem } from './ConnectWalletMenuItem';
import { ErrorDialog } from './ErrorDialog';
import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem';
import { WalletAccountIcon } from './WalletAccountIcon';

type Props = Readonly<{
children: React.ReactNode;
}>;

export function ConnectWalletMenu({ children }: Props) {
const { current: NO_ERROR } = useRef(Symbol());
const wallets = useWallets();
const [selectedWalletAccount, setSelectedWalletAccount] = useContext(SelectedWalletAccountContext);
const [error, setError] = useState<unknown | typeof NO_ERROR>(NO_ERROR);
const [forceClose, setForceClose] = useState(false);
const [_isPending, startTransition] = useTransition();
function renderItem(wallet: UiWallet) {
return (
<ErrorBoundary
fallbackRender={({ error }) => <UnconnectableWalletMenuItem error={error} wallet={wallet} />}
key={`wallet:${wallet.name}`}
>
<ConnectWalletMenuItem
onAccountSelect={account => {
startTransition(() => {
setSelectedWalletAccount(account);
setForceClose(true);
});
}}
onDisconnect={wallet => {
if (selectedWalletAccount && uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet)) {
startTransition(() => {
setSelectedWalletAccount(undefined);
});
}
}}
onError={setError}
wallet={wallet}
/>
</ErrorBoundary>
);
}
const walletsThatSupportStandardConnect = [];
const unconnectableWallets = [];
for (const wallet of wallets) {
if (wallet.features.includes(StandardConnect) && wallet.features.includes(StandardDisconnect)) {
walletsThatSupportStandardConnect.push(wallet);
} else {
unconnectableWallets.push(wallet);
}
}
return (
<>
<DropdownMenu.Root open={forceClose ? false : undefined} onOpenChange={setForceClose.bind(null, false)}>
<DropdownMenu.Trigger>
<Button>
{selectedWalletAccount ? (
<>
<WalletAccountIcon account={selectedWalletAccount} width="18" height="18" />
{selectedWalletAccount.address.slice(0, 8)}
</>
) : (
children
)}
<DropdownMenu.TriggerIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{wallets.length === 0 ? (
<Callout.Root color="orange" highContrast>
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Callout.Text>This browser has no wallets installed.</Callout.Text>
</Callout.Root>
) : (
<>
{walletsThatSupportStandardConnect.map(renderItem)}
{unconnectableWallets.length ? (
<>
<DropdownMenu.Separator />
{unconnectableWallets.map(renderItem)}
</>
) : null}
</>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
{error !== NO_ERROR ? <ErrorDialog error={error} onClose={() => setError(NO_ERROR)} /> : null}
</>
);
}
91 changes: 91 additions & 0 deletions examples/react-app/src/components/ConnectWalletMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { DropdownMenu, ThickChevronRightIcon } from '@radix-ui/themes';
import type { UiWallet, UiWalletAccount } from '@wallet-standard/react';
import { useConnect, useDisconnect } from '@wallet-standard/react';
import { useCallback, useContext } from 'react';

import { SelectedWalletAccountContext } from '../context/SelectedWalletAccountContext';
import { WalletMenuItemContent } from './WalletMenuItemContent';

type Props = Readonly<{
onAccountSelect(account: UiWalletAccount | undefined): void;
onDisconnect(wallet: UiWallet): void;
onError(err: unknown): void;
wallet: UiWallet;
}>;

export function ConnectWalletMenuItem({ onAccountSelect, onDisconnect, onError, wallet }: Props) {
const [isConnecting, connect] = useConnect(wallet);
const [isDisconnecting, disconnect] = useDisconnect(wallet);
const isPending = isConnecting || isDisconnecting;
const isConnected = wallet.accounts.length > 0;
const [selectedWalletAccount] = useContext(SelectedWalletAccountContext);
const handleClick = useCallback(async () => {
try {
if (isConnected) {
await disconnect();
onDisconnect(wallet);
} else {
const accounts = await connect();
if (accounts[0]) {
onAccountSelect(accounts[0]);
}
}
} catch (e) {
onError(e);
}
}, [connect, disconnect, isConnected, onAccountSelect, onDisconnect, onError, wallet]);
return (
<DropdownMenu.Sub open={!isConnected ? false : undefined}>
<DropdownMenuPrimitive.SubTrigger
asChild={false}
className={[
'rt-BaseMenuItem',
'rt-BaseMenuSubTrigger',
'rt-DropdownMenuItem',
'rt-DropdownMenuSubTrigger',
].join(' ')}
disabled={isPending}
onClick={!isConnected ? handleClick : undefined}
>
<WalletMenuItemContent loading={isPending} wallet={wallet} />
{isConnected ? (
<div className="rt-BaseMenuShortcut rt-DropdownMenuShortcut">
<ThickChevronRightIcon className="rt-BaseMenuSubTriggerIcon rt-DropdownMenuSubtriggerIcon" />
</div>
) : null}
</DropdownMenuPrimitive.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Label>Accounts</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={selectedWalletAccount?.address}>
{wallet.accounts.map(account => (
<DropdownMenu.RadioItem
key={account.address}
value={account.address}
onSelect={() => {
onAccountSelect(account);
}}
>
{account.address.slice(0, 8)}&hellip;
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Item
color="red"
onSelect={async e => {
e.preventDefault();
try {
await disconnect();
onDisconnect(wallet);
} catch (e) {
onError(e);
}
}}
>
Disconnect
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
);
}
51 changes: 51 additions & 0 deletions examples/react-app/src/components/DisconnectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ExclamationTriangleIcon, ExitIcon } from '@radix-ui/react-icons';
import { Button, Tooltip } from '@radix-ui/themes';
import type { UiWallet } from '@wallet-standard/react';
import { useDisconnect } from '@wallet-standard/react';
import { useState } from 'react';

import { NO_ERROR } from '../errors';

type Props = Readonly<{
wallet: UiWallet;
}>;

export function DisconnectButton({
wallet,
...buttonProps
}: Omit<React.ComponentProps<typeof Button>, 'color' | 'loading' | 'onClick'> & Props) {
const [isDisconnecting, disconnect] = useDisconnect(wallet);
const [lastError, setLastError] = useState<unknown | typeof NO_ERROR>(NO_ERROR);
return (
<Tooltip
content={
<>
Error:{' '}
{lastError && typeof lastError === 'object' && 'message' in lastError
? lastError.message
: String(lastError)}
</>
}
open={lastError !== NO_ERROR}
side="left"
>
<Button
{...buttonProps}
color="red"
loading={isDisconnecting}
onClick={async () => {
setLastError(NO_ERROR);
try {
await disconnect();
} catch (e) {
setLastError(e);
}
}}
variant="outline"
>
{lastError === NO_ERROR ? <ExitIcon /> : <ExclamationTriangleIcon />}
Disconnect
</Button>
</Tooltip>
);
}
33 changes: 33 additions & 0 deletions examples/react-app/src/components/ErrorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { AlertDialog, Blockquote, Button, Flex } from '@radix-ui/themes';

import { getErrorMessage } from '../errors';

type Props = Readonly<{
error: unknown;
onClose(): void;
}>;

export function ErrorDialog({ error, onClose }: Props) {
return (
<AlertDialog.Root
open={true}
onOpenChange={open => {
if (!open) {
onClose();
}
}}
>
<AlertDialog.Content>
<AlertDialog.Title color="red">We encountered the following error</AlertDialog.Title>
<AlertDialog.Description>
<Blockquote>{getErrorMessage(error, 'Unknown')}</Blockquote>
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button variant="solid">Close</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
);
}
23 changes: 23 additions & 0 deletions examples/react-app/src/components/FeatureNotSupportedCallout.1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Callout } from '@radix-ui/themes';
import React from 'react';
import { FallbackProps } from 'react-error-boundary';

import { getErrorMessage } from '../errors';

interface Props extends Callout.RootProps, FallbackProps {}

export function FeatureNotSupportedCallout({
error,
resetErrorBoundary: _,
...rootProps
}: Props): React.ReactElement<typeof Callout.Root> {
return (
<Callout.Root color="gray" size="1" {...rootProps} style={{ flexGrow: 1, ...rootProps.style }}>
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Callout.Text>{getErrorMessage(error, 'This account does not support this feature')}</Callout.Text>
</Callout.Root>
);
}
23 changes: 23 additions & 0 deletions examples/react-app/src/components/FeatureNotSupportedCallout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Callout } from '@radix-ui/themes';
import React from 'react';
import type { FallbackProps } from 'react-error-boundary';

import { getErrorMessage } from '../errors';

interface Props extends Callout.RootProps, FallbackProps {}

export function FeatureNotSupportedCallout({
error,
resetErrorBoundary: _,
...rootProps
}: Props): React.ReactElement<typeof Callout.Root> {
return (
<Callout.Root color="gray" size="1" {...rootProps} style={{ flexGrow: 1, ...rootProps.style }}>
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Callout.Text>{getErrorMessage(error, 'This account does not support this feature')}</Callout.Text>
</Callout.Root>
);
}
16 changes: 16 additions & 0 deletions examples/react-app/src/components/FeaturePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { DataList } from '@radix-ui/themes';
import React from 'react';

type Props = Readonly<{
children: React.ReactNode;
label: React.ReactNode;
}>;

export function FeaturePanel({ children, label }: Props) {
return (
<DataList.Item align={{ initial: 'start', sm: 'center' }}>
<DataList.Label>{label}</DataList.Label>
<DataList.Value style={{ width: '100%' }}>{children}</DataList.Value>
</DataList.Item>
);
}
55 changes: 55 additions & 0 deletions examples/react-app/src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Badge, Box, DropdownMenu, Flex, Heading, Spinner } from '@radix-ui/themes';
import { useContext, useTransition } from 'react';

import { ChainContext } from '../context/ChainContext';
import { ConnectWalletMenu } from './ConnectWalletMenu';

export function Nav() {
const { displayName: currentChainName, chain, setChain } = useContext(ChainContext);
const [isPending, startTransition] = useTransition();
const currentChainBadge = (
<Badge color="gray" style={{ verticalAlign: 'middle' }}>
{currentChainName} <Spinner loading={isPending} />
</Badge>
);
return (
<Box
style={{
backgroundColor: 'var(--gray-1)',
borderBottom: '1px solid var(--gray-a6)',
zIndex: 1,
}}
position="sticky"
p="3"
top="0"
>
<Flex gap="4" justify="between" align="center">
<Heading as="h1" size={{ initial: '4', xs: '6' }} truncate>
Solana React App{' '}
{setChain ? (
<DropdownMenu.Root>
<DropdownMenu.Trigger>{currentChainBadge}</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
onValueChange={value => {
startTransition(() => {
setChain(value as 'solana:${string}');
});
}}
value={chain}
>
<DropdownMenu.RadioItem value="solana:mainnet">Mainnet Beta</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="solana:devnet">Devnet</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="solana:testnet">Testnet</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
) : (
currentChainBadge
)}
</Heading>
<ConnectWalletMenu>Connect Wallet</ConnectWalletMenu>
</Flex>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { Blockquote, Box, Button, Dialog, Flex, Link, Select, Text, TextField } from '@radix-ui/themes';
import { useWalletAccountTransactionSendingSigner } from '@solana/react';
import {
address,
appendTransactionMessageInstruction,
createTransactionMessage,
getBase58Decoder,
lamports,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signAndSendTransactionMessageWithSigners,
} from '@solana/web3.js';
import { getTransferSolInstruction } from '@solana-program/system';
import { getUiWalletAccountStorageKey, type UiWalletAccount, useWallets } from '@wallet-standard/react';
import type { SyntheticEvent } from 'react';
import { useContext, useId, useMemo, useRef, useState } from 'react';
import { useSWRConfig } from 'swr';

import { ChainContext } from '../context/ChainContext';
import { RpcContext } from '../context/RpcContext';
import { ErrorDialog } from './ErrorDialog';
import { WalletMenuItemContent } from './WalletMenuItemContent';

type Props = Readonly<{
account: UiWalletAccount;
}>;

function solStringToLamports(solQuantityString: string) {
if (Number.isNaN(parseFloat(solQuantityString))) {
throw new Error('Could not parse token quantity: ' + String(solQuantityString));
}
const numDecimals = BigInt(solQuantityString.split('.')[1]?.length ?? 0);
const bigIntLamports = BigInt(solQuantityString.replace('.', '')) * 10n ** (9n - numDecimals);
return lamports(bigIntLamports);
}

export function SolanaSignAndSendTransactionFeaturePanel({ account }: Props) {
const { mutate } = useSWRConfig();
const { current: NO_ERROR } = useRef(Symbol());
const { rpc } = useContext(RpcContext);
const wallets = useWallets();
const [isSendingTransaction, setIsSendingTransaction] = useState(false);
const [error, setError] = useState<unknown | typeof NO_ERROR>(NO_ERROR);
const [lastSignature, setLastSignature] = useState<Uint8Array | undefined>();
const [solQuantityString, setSolQuantityString] = useState<string>('');
const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState<string | undefined>();
const recipientAccount = useMemo(() => {
if (recipientAccountStorageKey) {
for (const wallet of wallets) {
for (const account of wallet.accounts) {
if (getUiWalletAccountStorageKey(account) === recipientAccountStorageKey) {
return account;
}
}
}
}
}, [recipientAccountStorageKey, wallets]);
const { chain: currentChain, solanaExplorerClusterName } = useContext(ChainContext);
const transactionSendingSigner = useWalletAccountTransactionSendingSigner(account, currentChain);
const lamportsInputId = useId();
const recipientSelectId = useId();
return (
<Flex asChild gap="2" direction={{ initial: 'column', sm: 'row' }} style={{ width: '100%' }}>
<form
onSubmit={async e => {
e.preventDefault();
setError(NO_ERROR);
setIsSendingTransaction(true);
try {
const amount = solStringToLamports(solQuantityString);
if (!recipientAccount) {
throw new Error('The address of the recipient could not be found');
}
const { value: latestBlockhash } = await rpc
.getLatestBlockhash({ commitment: 'confirmed' })
.send();
const message = pipe(
createTransactionMessage({ version: 0 }),
m => setTransactionMessageFeePayerSigner(transactionSendingSigner, m),
m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
m =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
amount,
destination: address(recipientAccount.address),
source: transactionSendingSigner,
}),
m,
),
);
const signature = await signAndSendTransactionMessageWithSigners(
// @ts-expect-error FIXME(#2818)
message,
);
mutate({ address: transactionSendingSigner.address, chain: currentChain });
mutate({ address: recipientAccount.address, chain: currentChain });
setLastSignature(signature);
setSolQuantityString('');
} catch (e) {
setLastSignature(undefined);
setError(e);
} finally {
setIsSendingTransaction(false);
}
}}
>
<Box flexGrow="1" overflow="hidden">
<Flex gap="3" align="center">
<Box flexGrow="1" minWidth="90px" maxWidth="130px">
<TextField.Root
disabled={isSendingTransaction}
id={lamportsInputId}
placeholder="Amount"
onChange={(e: SyntheticEvent<HTMLInputElement>) =>
setSolQuantityString(e.currentTarget.value)
}
style={{ width: 'auto' }}
type="number"
value={solQuantityString}
>
<TextField.Slot side="right">{'\u25ce'}</TextField.Slot>
</TextField.Root>
</Box>
<Box flexShrink="0">
<Text as="label" color="gray" htmlFor={recipientSelectId} weight="medium">
To Account
</Text>
</Box>
<Select.Root
disabled={isSendingTransaction}
onValueChange={setRecipientAccountStorageKey}
value={recipientAccount ? getUiWalletAccountStorageKey(recipientAccount) : undefined}
>
<Select.Trigger
style={{ flexGrow: 1, flexShrink: 1, overflow: 'hidden' }}
placeholder="Select a Connected Account"
/>
<Select.Content>
{wallets.flatMap(wallet =>
wallet.accounts
.filter(({ chains }) => chains.includes(currentChain))
.map(account => {
const key = getUiWalletAccountStorageKey(account);
return (
<Select.Item key={key} value={key}>
<WalletMenuItemContent wallet={wallet}>
{account.address}
</WalletMenuItemContent>
</Select.Item>
);
}),
)}
</Select.Content>
</Select.Root>
</Flex>
</Box>
<Dialog.Root
open={!!lastSignature}
onOpenChange={open => {
if (!open) {
setLastSignature(undefined);
}
}}
>
<Dialog.Trigger>
<Button
color={error ? undefined : 'red'}
disabled={solQuantityString === '' || !recipientAccount}
loading={isSendingTransaction}
type="submit"
>
Transfer
</Button>
</Dialog.Trigger>
{lastSignature ? (
<Dialog.Content
onClick={e => {
e.stopPropagation();
}}
>
<Dialog.Title>You transferred tokens!</Dialog.Title>
<Flex direction="column" gap="2">
<Text>Signature:</Text>
<Blockquote>{getBase58Decoder().decode(lastSignature)}</Blockquote>
<Text>
<Link
href={`https://explorer.solana.com/tx/${getBase58Decoder().decode(
lastSignature,
)}?cluster=${solanaExplorerClusterName}`}
target="_blank"
>
View this transaction
</Link>{' '}
on Explorer
</Text>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button>Cool!</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
) : null}
</Dialog.Root>
{error !== NO_ERROR ? <ErrorDialog error={error} onClose={() => setError(NO_ERROR)} /> : null}
</form>
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useWalletAccountMessageSigner } from '@solana/react';
import type { Address } from '@solana/web3.js';
import type { ReadonlyUint8Array } from '@wallet-standard/core';
import type { UiWalletAccount } from '@wallet-standard/react';
import { useCallback } from 'react';

import { BaseSignMessageFeaturePanel } from './BaseSignMessageFeaturePanel';

type Props = Readonly<{
account: UiWalletAccount;
}>;

export function SolanaSignMessageFeaturePanel({ account }: Props) {
const messageSigner = useWalletAccountMessageSigner(account);
const signMessage = useCallback(
async (message: ReadonlyUint8Array) => {
const [result] = await messageSigner.modifyAndSignMessages([
{
content: message as Uint8Array,
signatures: {},
},
]);
const signature = result?.signatures[account.address as Address];
if (!signature) {
throw new Error();
}
return signature as ReadonlyUint8Array;
},
[account.address, messageSigner],
);
return <BaseSignMessageFeaturePanel signMessage={signMessage} />;
}
32 changes: 32 additions & 0 deletions examples/react-app/src/components/UnconnectableWalletMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Box, DropdownMenu, Text } from '@radix-ui/themes';
import type { UiWallet } from '@wallet-standard/react';
import { useState } from 'react';

import { ErrorDialog } from './ErrorDialog';
import { WalletMenuItemContent } from './WalletMenuItemContent';

type Props = Readonly<{
error: unknown;
wallet: UiWallet;
}>;

export function UnconnectableWalletMenuItem({ error, wallet }: Props) {
const [dialogIsOpen, setDialogIsOpen] = useState(false);
return (
<>
<DropdownMenu.Item disabled onClick={() => setDialogIsOpen(true)}>
<WalletMenuItemContent wallet={wallet}>
<Text style={{ textDecoration: 'line-through' }}>{wallet.name}</Text>
</WalletMenuItemContent>
<Box className="rt-BaseMenuShortcut rt-DropdownMenuShortcut">
<ExclamationTriangleIcon
className="rt-BaseMenuSubTriggerIcon rt-DropdownMenuSubtriggerIcon"
style={{ height: 14, width: 14 }}
/>
</Box>
</DropdownMenu.Item>
{dialogIsOpen ? <ErrorDialog error={error} onClose={() => setDialogIsOpen(false)} /> : null}
</>
);
}
24 changes: 24 additions & 0 deletions examples/react-app/src/components/WalletAccountIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { UiWalletAccount } from '@wallet-standard/react';
import { uiWalletAccountBelongsToUiWallet,useWallets } from '@wallet-standard/react';
import React from 'react';

type Props = React.ComponentProps<'img'> &
Readonly<{
account: UiWalletAccount;
}>;

export function WalletAccountIcon({ account, ...imgProps }: Props) {
const wallets = useWallets();
let icon;
if (account.icon) {
icon = account.icon;
} else {
for (const wallet of wallets) {
if (uiWalletAccountBelongsToUiWallet(account, wallet)) {
icon = wallet.icon;
break;
}
}
}
return icon ? <img src={icon} {...imgProps} /> : null;
}
25 changes: 25 additions & 0 deletions examples/react-app/src/components/WalletMenuItemContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Avatar, Flex, Spinner, Text } from '@radix-ui/themes';
import type { UiWallet } from '@wallet-standard/react';
import React from 'react';

type Props = Readonly<{
children?: React.ReactNode;
loading?: boolean;
wallet: UiWallet;
}>;

export function WalletMenuItemContent({ children, loading, wallet }: Props) {
return (
<Flex align="center" gap="2">
<Spinner loading={!!loading}>
<Avatar
fallback={<Text size="1">{wallet.name.slice(0, 1)}</Text>}
radius="none"
src={wallet.icon}
style={{ height: 18, width: 18 }}
/>
</Spinner>
<Text truncate>{children ?? wallet.name}</Text>
</Flex>
);
}
77 changes: 77 additions & 0 deletions examples/react-app/src/context/ChainContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { ClusterUrl } from '@solana/web3.js';
import { devnet, mainnet, testnet } from '@solana/web3.js';
import React, { createContext, useMemo, useState } from 'react';

import { localStorage } from '../storage';

const STORAGE_KEY = 'solana-wallet-standard-example-react:selected-chain';

type Context = Readonly<{
chain: `solana:${string}`;
displayName: string;
setChain?(chain: 'solana:${string}'): void;
solanaExplorerClusterName: 'devnet' | 'mainnet-beta' | 'testnet';
solanaRpcSubscriptionsUrl: ClusterUrl;
solanaRpcUrl: ClusterUrl;
}>;

export const ChainContext = createContext<Context>({
chain: 'solana:devnet',
displayName: 'Devnet',
solanaExplorerClusterName: 'devnet',
solanaRpcSubscriptionsUrl: devnet('wss://api.devnet.solana.com'),
solanaRpcUrl: devnet('https://api.devnet.solana.com'),
});

export function ChainContextProvider({ children }: { children: React.ReactNode }) {
const [chain, setChain] = useState(() => localStorage.getItem(STORAGE_KEY) ?? 'solana:devnet');
const contextValue = useMemo<Context>(() => {
switch (chain) {
case 'solana:mainnet':
return {
chain: 'solana:mainnet',
displayName: 'Mainnet Beta',
solanaExplorerClusterName: 'mainnet-beta',
solanaRpcSubscriptionsUrl: mainnet('wss://api.mainnet-beta.solana.com'),
solanaRpcUrl: mainnet('https://api.mainnet-beta.solana.com'),
};
case 'solana:testnet':
return {
chain: 'solana:testnet',
displayName: 'Testnet',
solanaExplorerClusterName: 'testnet',
solanaRpcSubscriptionsUrl: testnet('wss://api.testnet.solana.com'),
solanaRpcUrl: testnet('https://api.testnet.solana.com'),
};
case 'solana:devnet':
default:
if (chain !== 'solana:devnet') {
localStorage.removeItem(STORAGE_KEY);
console.error(`Unrecognized chain \`${chain}\``);
}
return {
chain: 'solana:devnet',
displayName: 'Devnet',
solanaExplorerClusterName: 'devnet',
solanaRpcSubscriptionsUrl: devnet('wss://api.devnet.solana.com'),
solanaRpcUrl: devnet('https://api.devnet.solana.com'),
};
}
}, [chain]);
return (
<ChainContext.Provider
value={useMemo(
() => ({
...contextValue,
setChain(chain) {
localStorage.setItem(STORAGE_KEY, chain);
setChain(chain);
},
}),
[contextValue],
)}
>
{children}
</ChainContext.Provider>
);
}
35 changes: 35 additions & 0 deletions examples/react-app/src/context/RpcContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Rpc, RpcSubscriptions, SolanaRpcApiMainnet, SolanaRpcSubscriptionsApi } from '@solana/web3.js';
import { createSolanaRpc, createSolanaRpcSubscriptions, devnet } from '@solana/web3.js';
import type { ReactNode } from 'react';
import { createContext, useContext, useMemo } from 'react';

import { ChainContext } from './ChainContext';

export const RpcContext = createContext<{
rpc: Rpc<SolanaRpcApiMainnet>; // Limit the API to only those methods found on Mainnet (ie. not `requestAirdrop`)
rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;
}>({
rpc: createSolanaRpc(devnet('https://api.devnet.solana.com')),
rpcSubscriptions: createSolanaRpcSubscriptions(devnet('wss://api.devnet.solana.com')),
});

type Props = Readonly<{
children: ReactNode;
}>;

export function RpcContextProvider({ children }: Props) {
const { solanaRpcSubscriptionsUrl, solanaRpcUrl } = useContext(ChainContext);
return (
<RpcContext.Provider
value={useMemo(
() => ({
rpc: createSolanaRpc(solanaRpcUrl),
rpcSubscriptions: createSolanaRpcSubscriptions(solanaRpcSubscriptionsUrl),
}),
[solanaRpcSubscriptionsUrl, solanaRpcUrl],
)}
>
{children}
</RpcContext.Provider>
);
}
113 changes: 113 additions & 0 deletions examples/react-app/src/context/SelectedWalletAccountContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { UiWallet, UiWalletAccount } from '@wallet-standard/react';
import {
getUiWalletAccountStorageKey,
uiWalletAccountBelongsToUiWallet,
uiWalletAccountsAreSame,
useWallets,
} from '@wallet-standard/react';
import { createContext, useEffect, useMemo, useState } from 'react';

import { localStorage } from '../storage';

type State = UiWalletAccount | undefined;

const STORAGE_KEY = 'solana-wallet-standard-example-react:selected-wallet-and-address';

export const SelectedWalletAccountContext = createContext<
readonly [selectedWalletAccount: State, setSelectedWalletAccount: React.Dispatch<React.SetStateAction<State>>]
>([
undefined,
() => {
/* empty */
},
]);

let wasSetInvoked = false;
function getSavedWalletAccount(wallets: readonly UiWallet[]): UiWalletAccount | undefined {
if (wasSetInvoked) {
// After the user makes an explicit choice of wallet, stop trying to auto-select the
// saved wallet, if and when it appears.
return;
}
try {
const savedWalletNameAndAddress = localStorage.getItem(STORAGE_KEY);
if (!savedWalletNameAndAddress || typeof savedWalletNameAndAddress !== 'string') {
return;
}
const [savedWalletName, savedAccountAddress] = savedWalletNameAndAddress.split(':');
if (!savedWalletName || !savedAccountAddress) {
return;
}
for (const wallet of wallets) {
if (wallet.name === savedWalletName) {
for (const account of wallet.accounts) {
if (account.address === savedAccountAddress) {
return account;
}
}
}
}
} catch {
return;
}
}

export function SelectedWalletAccountContextProvider({ children }: { children: React.ReactNode }) {
const wallets = useWallets();
const [selectedWalletAccount, setSelectedWalletAccountInternal] = useState<State>(() =>
getSavedWalletAccount(wallets),
);
const setSelectedWalletAccount: React.Dispatch<React.SetStateAction<State>> = s => {
setSelectedWalletAccountInternal(prevSelectedWalletAccount => {
wasSetInvoked = true;
const nextWalletAccount = typeof s === 'function' ? s(prevSelectedWalletAccount) : s;
const accountKey = nextWalletAccount ? getUiWalletAccountStorageKey(nextWalletAccount) : undefined;
try {
if (accountKey) {
localStorage.setItem(STORAGE_KEY, accountKey);
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
/* empty */
}
return nextWalletAccount;
});
};
useEffect(() => {
const savedWalletAccount = getSavedWalletAccount(wallets);
if (savedWalletAccount) {
setSelectedWalletAccountInternal(savedWalletAccount);
}
}, [wallets]);
const walletAccount = useMemo(() => {
if (selectedWalletAccount) {
for (const uiWallet of wallets) {
for (const uiWalletAccount of uiWallet.accounts) {
if (uiWalletAccountsAreSame(selectedWalletAccount, uiWalletAccount)) {
return uiWalletAccount;
}
}
if (uiWalletAccountBelongsToUiWallet(selectedWalletAccount, uiWallet) && uiWallet.accounts[0]) {
// If the selected account belongs to this connected wallet, at least, then
// select one of its accounts.
return uiWallet.accounts[0];
}
}
}
}, [selectedWalletAccount, wallets]);
useEffect(() => {
// If there is a selected wallet account but the wallet to which it belongs has since
// disconnected, clear the selected wallet.
if (selectedWalletAccount && !walletAccount) {
setSelectedWalletAccountInternal(undefined);
}
}, [selectedWalletAccount, walletAccount]);
return (
<SelectedWalletAccountContext.Provider
value={useMemo(() => [walletAccount, setSelectedWalletAccount] as const, [walletAccount])}
>
{children}
</SelectedWalletAccountContext.Provider>
);
}
66 changes: 66 additions & 0 deletions examples/react-app/src/errors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Code, Flex, Text } from '@radix-ui/themes';
import {
isWalletStandardError,
WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_CHAIN_UNSUPPORTED,
WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED,
WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED,
} from '@wallet-standard/core';
import React from 'react';

export const NO_ERROR = Symbol();

export function getErrorMessage(err: unknown, fallbackMessage: React.ReactNode): React.ReactNode {
if (isWalletStandardError(err, WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)) {
return (
<>
This account does not support the <Code>{err.context.featureName}</Code> feature
</>
);
} else if (isWalletStandardError(err, WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED)) {
return (
<Flex direction="column" gap="4">
<Text as="p">
The wallet '{err.context.walletName}' (
{err.context.supportedChains.sort().map((chain, ii, { length }) => (
<React.Fragment key={chain}>
<Code>{chain}</Code>
{ii === length - 1 ? null : ', '}
</React.Fragment>
))}
) does not support the <Code>{err.context.featureName}</Code> feature.
</Text>
<Text as="p" trim="end">
Features supported:
<ul>
{err.context.supportedFeatures.sort().map(featureName => (
<li key={featureName}>
<Code>{featureName}</Code>
</li>
))}
</ul>
</Text>
</Flex>
);
} else if (isWalletStandardError(err, WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_CHAIN_UNSUPPORTED)) {
return (
<Flex direction="column" gap="4">
<Text as="p">
This account does not support the chain <Code>{err.context.chain}</Code>.
</Text>
<Text as="p" trim="end">
Chains supported:
<ul>
{err.context.supportedChains.sort().map(chain => (
<li key={chain}>
<Code>{chain}</Code>
</li>
))}
</ul>
</Text>
</Flex>
);
} else if (err && typeof err === 'object' && 'message' in err) {
return String(err.message);
}
return fallbackMessage;
}
1 change: 1 addition & 0 deletions examples/react-app/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import './reset.css';
33 changes: 33 additions & 0 deletions examples/react-app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import './index.css';
import '@radix-ui/themes/styles.css';

import { Flex, Section, Theme } from '@radix-ui/themes';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import { Nav } from './components/Nav.tsx';
import { ChainContextProvider } from './context/ChainContext.tsx';
import { RpcContextProvider } from './context/RpcContext.tsx';
import { SelectedWalletAccountContextProvider } from './context/SelectedWalletAccountContext.tsx';
import Root from './routes/root.tsx';

const rootNode = document.getElementById('root')!;
const root = createRoot(rootNode);
root.render(
<StrictMode>
<Theme>
<Flex direction="column">
<ChainContextProvider>
<SelectedWalletAccountContextProvider>
<RpcContextProvider>
<Nav />
<Section>
<Root />
</Section>
</RpcContextProvider>
</SelectedWalletAccountContextProvider>
</ChainContextProvider>
</Flex>
</Theme>
</StrictMode>,
);
74 changes: 74 additions & 0 deletions examples/react-app/src/reset.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}

/* Remove default margin */
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin: 0;
}

/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role='list'],
ol[role='list'] {
list-style: none;
}

/* Set core root defaults */
html:focus-within {
scroll-behavior: smooth;
}

/* Set core body defaults */
body {
min-height: 100vh;
text-rendering: optimizeSpeed;
line-height: 1.5;
}

/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
}

/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}

/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
}

/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}

*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
73 changes: 73 additions & 0 deletions examples/react-app/src/routes/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Box, Code, Container, DataList, Flex, Heading, Spinner, Text } from '@radix-ui/themes';
import { Suspense, useContext } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import { Balance } from '../components/Balance';
import { FeatureNotSupportedCallout } from '../components/FeatureNotSupportedCallout';
import { FeaturePanel } from '../components/FeaturePanel';
import { SolanaSignAndSendTransactionFeaturePanel } from '../components/SolanaSignAndSendTransactionFeaturePanel';
import { SolanaSignMessageFeaturePanel } from '../components/SolanaSignMessageFeaturePanel';
import { WalletAccountIcon } from '../components/WalletAccountIcon';
import { ChainContext } from '../context/ChainContext';
import { SelectedWalletAccountContext } from '../context/SelectedWalletAccountContext';

function Root() {
const { chain } = useContext(ChainContext);
const [selectedWalletAccount] = useContext(SelectedWalletAccountContext);
return (
<Container mx={{ initial: '3', xs: '6' }}>
{selectedWalletAccount ? (
<Flex gap="6" direction="column">
<Flex gap="2">
<Flex align="center" gap="3" flexGrow="1">
<WalletAccountIcon account={selectedWalletAccount} height="48" width="48" />
<Box>
<Heading as="h4" size="3">
{selectedWalletAccount.label ?? 'Unlabeled Account'}
</Heading>
<Code variant="outline" truncate size={{ initial: '1', xs: '2' }}>
{selectedWalletAccount.address}
</Code>
</Box>
</Flex>
<Flex direction="column" align="start">
<Heading as="h4" size="3">
Balance
</Heading>
<ErrorBoundary
fallback={<Text>&ndash;</Text>}
key={`${selectedWalletAccount.address}:${chain}`}
>
<Suspense
fallback={
<Spinner loading>
<Balance account={selectedWalletAccount} />
</Spinner>
}
>
<Balance account={selectedWalletAccount} />
</Suspense>
</ErrorBoundary>
</Flex>
</Flex>
<DataList.Root orientation={{ initial: 'vertical', sm: 'horizontal' }} size="3">
<FeaturePanel label="Sign Message">
<ErrorBoundary FallbackComponent={FeatureNotSupportedCallout}>
<SolanaSignMessageFeaturePanel account={selectedWalletAccount} />
</ErrorBoundary>
</FeaturePanel>
<FeaturePanel label="Sign And Send Transaction">
<ErrorBoundary FallbackComponent={FeatureNotSupportedCallout}>
<SolanaSignAndSendTransactionFeaturePanel account={selectedWalletAccount} />
</ErrorBoundary>
</FeaturePanel>
</DataList.Root>
</Flex>
) : (
<Text as="p">Click “Connect Wallet” to get started.</Text>
)}
</Container>
);
}

export default Root;
41 changes: 41 additions & 0 deletions examples/react-app/src/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
let storage: Storage | undefined;
try {
if (typeof window !== 'undefined' && window.localStorage) {
storage = window.localStorage;
}
} catch {
/* empty */
}

function guard<TArgs extends unknown[], TReturn, TFallbackReturn>(
fn: (...args: TArgs) => TReturn,
fallbackReturn: TFallbackReturn,
): (...args: TArgs) => TFallbackReturn | TReturn;
function guard<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => TReturn,
): (...args: TArgs) => TReturn | undefined;
function guard<TArgs extends unknown[], TReturn, TFallbackReturn>(
fn: (...args: TArgs) => TReturn,
fallbackReturn?: TFallbackReturn,
): (...args: TArgs) => TFallbackReturn | TReturn | undefined {
return (...args) => {
try {
return fn(...args);
} catch (e) {
console.error(e);
return fallbackReturn;
}
};
}

export const localStorage: Pick<Storage, 'getItem' | 'removeItem' | 'setItem'> = {
getItem: guard(k => {
return storage?.getItem(k) ?? null;
}, null),
removeItem: guard(k => {
storage?.removeItem(k);
}),
setItem: guard((k, v) => {
storage?.setItem(k, v);
}),
};
1 change: 1 addition & 0 deletions examples/react-app/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
25 changes: 25 additions & 0 deletions examples/react-app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts", "src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
11 changes: 11 additions & 0 deletions examples/react-app/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
20 changes: 20 additions & 0 deletions examples/react-app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import react from '@vitejs/plugin-react-swc';
import { defineConfig, Plugin } from 'vite';

function replaceProcessEnv(mode: string): Plugin {
const nodeEnvRegex = /process(\.env(\.NODE_ENV)|\["env"\]\.NODE_ENV)/g;
return {
name: 'replace-process-env',
renderChunk(code) {
return code.replace(nodeEnvRegex, JSON.stringify(mode));
},
};
}

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
define: {
'process.env': process.env,
},
plugins: [react(), replaceProcessEnv(mode)],
}));
3,612 changes: 2,761 additions & 851 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
packages:
- "examples/*"
- "packages/*"

0 comments on commit ca58fa4

Please sign in to comment.