Skip to content

Commit

Permalink
Display owned tokens on account details page (solana-labs#11335)
Browse files Browse the repository at this point in the history
  • Loading branch information
jstarry committed Aug 2, 2020
1 parent b6ea9f1 commit 4052008
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 6 deletions.
117 changes: 114 additions & 3 deletions explorer/src/components/AccountDetails.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { PublicKey, StakeProgram } from "@solana/web3.js";
import { PublicKey, StakeProgram, TokenAccountInfo } from "@solana/web3.js";
import {
FetchStatus,
useFetchAccountInfo,
Expand All @@ -16,6 +16,10 @@ import ErrorCard from "components/common/ErrorCard";
import LoadingCard from "components/common/LoadingCard";
import TableCardBody from "components/common/TableCardBody";
import { useFetchAccountHistory } from "providers/accounts/history";
import {
useFetchAccountOwnedTokens,
useAccountOwnedTokens,
} from "providers/accounts/tokens";

type Props = { address: string };
export default function AccountDetails({ address }: Props) {
Expand All @@ -36,6 +40,7 @@ export default function AccountDetails({ address }: Props) {
</div>
</div>
{pubkey && <AccountCards pubkey={pubkey} />}
{pubkey && <TokensCard pubkey={pubkey} />}
{pubkey && <HistoryCard pubkey={pubkey} />}
</div>
);
Expand Down Expand Up @@ -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 }) {
const address = pubkey.toBase58();
const info = useAccountInfo(address);
Expand All @@ -140,7 +251,7 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
history.fetchedRange === undefined
) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard />;
return <LoadingCard message="Loading history" />;
}

return (
Expand All @@ -150,7 +261,7 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {

if (history.fetched.length === 0) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard />;
return <LoadingCard message="Loading history" />;
}
return (
<ErrorCard
Expand Down
4 changes: 2 additions & 2 deletions explorer/src/components/common/LoadingCard.tsx
@@ -1,11 +1,11 @@
import React from "react";

export default function LoadingCard() {
export default function LoadingCard({ message }: { message?: string }) {
return (
<div className="card">
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
{message || "Loading"}
</div>
</div>
);
Expand Down
5 changes: 4 additions & 1 deletion explorer/src/providers/accounts/index.tsx
Expand Up @@ -3,6 +3,7 @@ import { StakeAccount } from "solana-sdk-wasm";
import { PublicKey, Connection, StakeProgram } from "@solana/web3.js";
import { useCluster, ClusterStatus } from "../cluster";
import { HistoryProvider } from "./history";
import { TokensProvider } from "./tokens";
export { useAccountHistory } from "./history";

export enum FetchStatus {
Expand Down Expand Up @@ -137,7 +138,9 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
<HistoryProvider>{children}</HistoryProvider>
<TokensProvider>
<HistoryProvider>{children}</HistoryProvider>
</TokensProvider>
</DispatchContext.Provider>
</StateContext.Provider>
);
Expand Down
134 changes: 134 additions & 0 deletions explorer/src/providers/accounts/tokens.tsx
@@ -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);
};
}

0 comments on commit 4052008

Please sign in to comment.