Display owned tokens on account details page (#11335)
This commit is contained in:
parent
b6ea9f1861
commit
4052008c42
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { PublicKey, StakeProgram } from "@solana/web3.js";
|
import { PublicKey, StakeProgram, TokenAccountInfo } from "@solana/web3.js";
|
||||||
import {
|
import {
|
||||||
FetchStatus,
|
FetchStatus,
|
||||||
useFetchAccountInfo,
|
useFetchAccountInfo,
|
||||||
|
@ -16,6 +16,10 @@ import ErrorCard from "components/common/ErrorCard";
|
||||||
import LoadingCard from "components/common/LoadingCard";
|
import LoadingCard from "components/common/LoadingCard";
|
||||||
import TableCardBody from "components/common/TableCardBody";
|
import TableCardBody from "components/common/TableCardBody";
|
||||||
import { useFetchAccountHistory } from "providers/accounts/history";
|
import { useFetchAccountHistory } from "providers/accounts/history";
|
||||||
|
import {
|
||||||
|
useFetchAccountOwnedTokens,
|
||||||
|
useAccountOwnedTokens,
|
||||||
|
} from "providers/accounts/tokens";
|
||||||
|
|
||||||
type Props = { address: string };
|
type Props = { address: string };
|
||||||
export default function AccountDetails({ address }: Props) {
|
export default function AccountDetails({ address }: Props) {
|
||||||
|
@ -36,6 +40,7 @@ export default function AccountDetails({ address }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{pubkey && <AccountCards pubkey={pubkey} />}
|
{pubkey && <AccountCards pubkey={pubkey} />}
|
||||||
|
{pubkey && <TokensCard pubkey={pubkey} />}
|
||||||
{pubkey && <HistoryCard pubkey={pubkey} />}
|
{pubkey && <HistoryCard pubkey={pubkey} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -125,6 +130,112 @@ function UnknownAccountCard({ account }: { account: Account }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TokensCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
|
const address = pubkey.toBase58();
|
||||||
|
const ownedTokens = useAccountOwnedTokens(address);
|
||||||
|
const fetchAccountTokens = useFetchAccountOwnedTokens();
|
||||||
|
const refresh = () => fetchAccountTokens(pubkey);
|
||||||
|
|
||||||
|
if (ownedTokens === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, tokens } = ownedTokens;
|
||||||
|
const fetching = status === FetchStatus.Fetching;
|
||||||
|
if (fetching && (tokens === undefined || tokens.length === 0)) {
|
||||||
|
return <LoadingCard message="Loading owned tokens" />;
|
||||||
|
} else if (tokens === undefined) {
|
||||||
|
return <ErrorCard retry={refresh} text="Failed to fetch owned tokens" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
retry={refresh}
|
||||||
|
retryText="Try Again"
|
||||||
|
text={"No owned tokens found"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedTokens = new Map<string, TokenAccountInfo>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
const mintAddress = token.mint.toBase58();
|
||||||
|
const tokenInfo = mappedTokens.get(mintAddress);
|
||||||
|
if (tokenInfo) {
|
||||||
|
tokenInfo.amount += token.amount;
|
||||||
|
} else {
|
||||||
|
mappedTokens.set(mintAddress, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsList: React.ReactNode[] = [];
|
||||||
|
mappedTokens.forEach((tokenInfo, mintAddress) => {
|
||||||
|
const balance = tokenInfo.amount;
|
||||||
|
detailsList.push(
|
||||||
|
<tr key={mintAddress}>
|
||||||
|
<td>
|
||||||
|
<Copyable text={mintAddress}>
|
||||||
|
<code>{mintAddress}</code>
|
||||||
|
</Copyable>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{balance}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
to={(location) => ({
|
||||||
|
...location,
|
||||||
|
pathname: "/account/" + mintAddress,
|
||||||
|
})}
|
||||||
|
className="btn btn-rounded-circle btn-white btn-sm"
|
||||||
|
>
|
||||||
|
<span className="fe fe-arrow-right"></span>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header align-items-center">
|
||||||
|
<h3 className="card-header-title">Tokens</h3>
|
||||||
|
<button
|
||||||
|
className="btn btn-white btn-sm"
|
||||||
|
disabled={fetching}
|
||||||
|
onClick={refresh}
|
||||||
|
>
|
||||||
|
{fetching ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
||||||
|
Loading
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="fe fe-refresh-cw mr-2"></span>
|
||||||
|
Refresh
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-responsive mb-0">
|
||||||
|
<table className="table table-sm table-nowrap card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-muted">Token Address</th>
|
||||||
|
<th className="text-muted">Balance</th>
|
||||||
|
<th className="text-muted">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="list">{detailsList}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
const address = pubkey.toBase58();
|
const address = pubkey.toBase58();
|
||||||
const info = useAccountInfo(address);
|
const info = useAccountInfo(address);
|
||||||
|
@ -140,7 +251,7 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
history.fetchedRange === undefined
|
history.fetchedRange === undefined
|
||||||
) {
|
) {
|
||||||
if (history.status === FetchStatus.Fetching) {
|
if (history.status === FetchStatus.Fetching) {
|
||||||
return <LoadingCard />;
|
return <LoadingCard message="Loading history" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -150,7 +261,7 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
|
|
||||||
if (history.fetched.length === 0) {
|
if (history.fetched.length === 0) {
|
||||||
if (history.status === FetchStatus.Fetching) {
|
if (history.status === FetchStatus.Fetching) {
|
||||||
return <LoadingCard />;
|
return <LoadingCard message="Loading history" />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function LoadingCard() {
|
export default function LoadingCard({ message }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body text-center">
|
<div className="card-body text-center">
|
||||||
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
||||||
Loading
|
{message || "Loading"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { StakeAccount } from "solana-sdk-wasm";
|
||||||
import { PublicKey, Connection, StakeProgram } from "@solana/web3.js";
|
import { PublicKey, Connection, StakeProgram } from "@solana/web3.js";
|
||||||
import { useCluster, ClusterStatus } from "../cluster";
|
import { useCluster, ClusterStatus } from "../cluster";
|
||||||
import { HistoryProvider } from "./history";
|
import { HistoryProvider } from "./history";
|
||||||
|
import { TokensProvider } from "./tokens";
|
||||||
export { useAccountHistory } from "./history";
|
export { useAccountHistory } from "./history";
|
||||||
|
|
||||||
export enum FetchStatus {
|
export enum FetchStatus {
|
||||||
|
@ -137,7 +138,9 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
||||||
return (
|
return (
|
||||||
<StateContext.Provider value={state}>
|
<StateContext.Provider value={state}>
|
||||||
<DispatchContext.Provider value={dispatch}>
|
<DispatchContext.Provider value={dispatch}>
|
||||||
|
<TokensProvider>
|
||||||
<HistoryProvider>{children}</HistoryProvider>
|
<HistoryProvider>{children}</HistoryProvider>
|
||||||
|
</TokensProvider>
|
||||||
</DispatchContext.Provider>
|
</DispatchContext.Provider>
|
||||||
</StateContext.Provider>
|
</StateContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Connection, PublicKey, TokenAccountInfo } from "@solana/web3.js";
|
||||||
|
import { FetchStatus, useAccounts } from "./index";
|
||||||
|
import { useCluster } from "../cluster";
|
||||||
|
|
||||||
|
interface AccountTokens {
|
||||||
|
status: FetchStatus;
|
||||||
|
tokens?: TokenAccountInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Update {
|
||||||
|
pubkey: PublicKey;
|
||||||
|
status: FetchStatus;
|
||||||
|
tokens?: TokenAccountInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action = Update | "clear";
|
||||||
|
type State = { [address: string]: AccountTokens };
|
||||||
|
type Dispatch = (action: Action) => void;
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
if (action === "clear") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = action.pubkey.toBase58();
|
||||||
|
let addressEntry = state[address];
|
||||||
|
if (addressEntry && action.status === FetchStatus.Fetching) {
|
||||||
|
addressEntry = {
|
||||||
|
...addressEntry,
|
||||||
|
status: FetchStatus.Fetching,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
addressEntry = {
|
||||||
|
tokens: action.tokens,
|
||||||
|
status: action.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[address]: addressEntry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const StateContext = React.createContext<State | undefined>(undefined);
|
||||||
|
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
|
||||||
|
|
||||||
|
type ProviderProps = { children: React.ReactNode };
|
||||||
|
export function TokensProvider({ children }: ProviderProps) {
|
||||||
|
const [state, dispatch] = React.useReducer(reducer, {});
|
||||||
|
const { url } = useCluster();
|
||||||
|
const { accounts, lastFetchedAddress } = useAccounts();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch("clear");
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
// Fetch history for new accounts
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (lastFetchedAddress) {
|
||||||
|
const infoFetched =
|
||||||
|
accounts[lastFetchedAddress] &&
|
||||||
|
accounts[lastFetchedAddress].lamports !== undefined;
|
||||||
|
const noRecord = !state[lastFetchedAddress];
|
||||||
|
if (infoFetched && noRecord) {
|
||||||
|
fetchAccountTokens(dispatch, new PublicKey(lastFetchedAddress), url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [accounts, lastFetchedAddress]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StateContext.Provider value={state}>
|
||||||
|
<DispatchContext.Provider value={dispatch}>
|
||||||
|
{children}
|
||||||
|
</DispatchContext.Provider>
|
||||||
|
</StateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_PROGRAM_ID = new PublicKey(
|
||||||
|
"TokenSVp5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o"
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchAccountTokens(
|
||||||
|
dispatch: Dispatch,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
url: string
|
||||||
|
) {
|
||||||
|
dispatch({
|
||||||
|
status: FetchStatus.Fetching,
|
||||||
|
pubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
let status;
|
||||||
|
let tokens;
|
||||||
|
try {
|
||||||
|
const { value } = await new Connection(
|
||||||
|
url,
|
||||||
|
"recent"
|
||||||
|
).getTokenAccountsByOwner(pubkey, { programId: TOKEN_PROGRAM_ID });
|
||||||
|
tokens = value.map((accountInfo) => accountInfo.account.data);
|
||||||
|
status = FetchStatus.Fetched;
|
||||||
|
} catch (error) {
|
||||||
|
status = FetchStatus.FetchFailed;
|
||||||
|
}
|
||||||
|
dispatch({ status, tokens, pubkey });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountOwnedTokens(address: string) {
|
||||||
|
const context = React.useContext(StateContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
`useAccountOwnedTokens must be used within a AccountsProvider`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context[address];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetchAccountOwnedTokens() {
|
||||||
|
const dispatch = React.useContext(DispatchContext);
|
||||||
|
if (!dispatch) {
|
||||||
|
throw new Error(
|
||||||
|
`useFetchAccountOwnedTokens must be used within a AccountsProvider`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = useCluster();
|
||||||
|
return (pubkey: PublicKey) => {
|
||||||
|
fetchAccountTokens(dispatch, pubkey, url);
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue