281 lines
8.3 KiB
TypeScript
281 lines
8.3 KiB
TypeScript
import React from "react";
|
|
import { PublicKey } from "@solana/web3.js";
|
|
import { FetchStatus } from "providers/cache";
|
|
import {
|
|
useFetchAccountOwnedTokens,
|
|
useAccountOwnedTokens,
|
|
TokenInfoWithPubkey,
|
|
} from "providers/accounts/tokens";
|
|
import { ErrorCard } from "components/common/ErrorCard";
|
|
import { LoadingCard } from "components/common/LoadingCard";
|
|
import { Address } from "components/common/Address";
|
|
import { useQuery } from "utils/url";
|
|
import { Link } from "react-router-dom";
|
|
import { Location } from "history";
|
|
import { useTokenRegistry } from "providers/mints/token-registry";
|
|
import { BigNumber } from "bignumber.js";
|
|
import { Identicon } from "components/common/Identicon";
|
|
|
|
type Display = "summary" | "detail" | null;
|
|
|
|
const SMALL_IDENTICON_WIDTH = 16;
|
|
|
|
const useQueryDisplay = (): Display => {
|
|
const query = useQuery();
|
|
const filter = query.get("display");
|
|
if (filter === "summary" || filter === "detail") {
|
|
return filter;
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
|
const address = pubkey.toBase58();
|
|
const ownedTokens = useAccountOwnedTokens(address);
|
|
const fetchAccountTokens = useFetchAccountOwnedTokens();
|
|
const refresh = () => fetchAccountTokens(pubkey);
|
|
const [showDropdown, setDropdown] = React.useState(false);
|
|
const display = useQueryDisplay();
|
|
|
|
// Fetch owned tokens
|
|
React.useEffect(() => {
|
|
if (!ownedTokens) refresh();
|
|
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (ownedTokens === undefined) {
|
|
return null;
|
|
}
|
|
|
|
const { status } = ownedTokens;
|
|
const tokens = ownedTokens.data?.tokens;
|
|
const fetching = status === FetchStatus.Fetching;
|
|
if (fetching && (tokens === undefined || tokens.length === 0)) {
|
|
return <LoadingCard message="Loading token holdings" />;
|
|
} else if (tokens === undefined) {
|
|
return <ErrorCard retry={refresh} text="Failed to fetch token holdings" />;
|
|
}
|
|
|
|
if (tokens.length === 0) {
|
|
return (
|
|
<ErrorCard
|
|
retry={refresh}
|
|
retryText="Try Again"
|
|
text={"No token holdings found"}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (tokens.length > 100) {
|
|
return (
|
|
<ErrorCard text="Token holdings is not available for accounts with over 100 token accounts" />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{showDropdown && (
|
|
<div className="dropdown-exit" onClick={() => setDropdown(false)} />
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="card-header align-items-center">
|
|
<h3 className="card-header-title">Token Holdings</h3>
|
|
<DisplayDropdown
|
|
display={display}
|
|
toggle={() => setDropdown((show) => !show)}
|
|
show={showDropdown}
|
|
/>
|
|
</div>
|
|
{display === "detail" ? (
|
|
<HoldingsDetailTable tokens={tokens} />
|
|
) : (
|
|
<HoldingsSummaryTable tokens={tokens} />
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function HoldingsDetailTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
|
const detailsList: React.ReactNode[] = [];
|
|
const { tokenRegistry } = useTokenRegistry();
|
|
const showLogos = tokens.some(
|
|
(t) => tokenRegistry.get(t.info.mint.toBase58())?.logoURI !== undefined
|
|
);
|
|
tokens.forEach((tokenAccount) => {
|
|
const address = tokenAccount.pubkey.toBase58();
|
|
const mintAddress = tokenAccount.info.mint.toBase58();
|
|
const tokenDetails = tokenRegistry.get(mintAddress);
|
|
detailsList.push(
|
|
<tr key={address}>
|
|
{showLogos && (
|
|
<td className="w-1 p-0 text-center">
|
|
{tokenDetails?.logoURI ? (
|
|
<img
|
|
src={tokenDetails.logoURI}
|
|
alt="token icon"
|
|
className="token-icon rounded-circle border border-4 border-gray-dark"
|
|
/>
|
|
) : (
|
|
<Identicon
|
|
address={address}
|
|
className="avatar-img identicon-wrapper identicon-wrapper-small"
|
|
style={{ width: SMALL_IDENTICON_WIDTH }}
|
|
/>
|
|
)}
|
|
</td>
|
|
)}
|
|
<td>
|
|
<Address pubkey={tokenAccount.pubkey} link truncate />
|
|
</td>
|
|
<td>
|
|
<Address pubkey={tokenAccount.info.mint} link truncate />
|
|
</td>
|
|
<td>
|
|
{tokenAccount.info.tokenAmount.uiAmountString}{" "}
|
|
{tokenDetails && tokenDetails.symbol}
|
|
</td>
|
|
</tr>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="table-responsive mb-0">
|
|
<table className="table table-sm table-nowrap card-table">
|
|
<thead>
|
|
<tr>
|
|
{showLogos && (
|
|
<th className="text-muted w-1 p-0 text-center">Logo</th>
|
|
)}
|
|
<th className="text-muted">Account Address</th>
|
|
<th className="text-muted">Mint Address</th>
|
|
<th className="text-muted">Balance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="list">{detailsList}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HoldingsSummaryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
|
const { tokenRegistry } = useTokenRegistry();
|
|
const mappedTokens = new Map<string, string>();
|
|
for (const { info: token } of tokens) {
|
|
const mintAddress = token.mint.toBase58();
|
|
const totalByMint = mappedTokens.get(mintAddress);
|
|
|
|
let amount = token.tokenAmount.uiAmountString;
|
|
if (totalByMint !== undefined) {
|
|
amount = new BigNumber(totalByMint)
|
|
.plus(token.tokenAmount.uiAmountString)
|
|
.toString();
|
|
}
|
|
|
|
mappedTokens.set(mintAddress, amount);
|
|
}
|
|
|
|
const detailsList: React.ReactNode[] = [];
|
|
const showLogos = tokens.some(
|
|
(t) => tokenRegistry.get(t.info.mint.toBase58())?.logoURI !== undefined
|
|
);
|
|
mappedTokens.forEach((totalByMint, mintAddress) => {
|
|
const tokenDetails = tokenRegistry.get(mintAddress);
|
|
detailsList.push(
|
|
<tr key={mintAddress}>
|
|
{showLogos && (
|
|
<td className="w-1 p-0 text-center">
|
|
{tokenDetails?.logoURI ? (
|
|
<img
|
|
src={tokenDetails.logoURI}
|
|
alt="token icon"
|
|
className="token-icon rounded-circle border border-4 border-gray-dark"
|
|
/>
|
|
) : (
|
|
<Identicon
|
|
address={mintAddress}
|
|
className="avatar-img identicon-wrapper identicon-wrapper-small"
|
|
style={{ width: SMALL_IDENTICON_WIDTH }}
|
|
/>
|
|
)}
|
|
</td>
|
|
)}
|
|
<td>
|
|
<Address pubkey={new PublicKey(mintAddress)} link useMetadata />
|
|
</td>
|
|
<td>
|
|
{totalByMint} {tokenDetails && tokenDetails.symbol}
|
|
</td>
|
|
</tr>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="table-responsive mb-0">
|
|
<table className="table table-sm table-nowrap card-table">
|
|
<thead>
|
|
<tr>
|
|
{showLogos && (
|
|
<th className="text-muted w-1 p-0 text-center">Logo</th>
|
|
)}
|
|
<th className="text-muted">Mint Address</th>
|
|
<th className="text-muted">Total Balance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="list">{detailsList}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type DropdownProps = {
|
|
display: Display;
|
|
toggle: () => void;
|
|
show: boolean;
|
|
};
|
|
|
|
const DisplayDropdown = ({ display, toggle, show }: DropdownProps) => {
|
|
const buildLocation = (location: Location, display: Display) => {
|
|
const params = new URLSearchParams(location.search);
|
|
if (display === null) {
|
|
params.delete("display");
|
|
} else {
|
|
params.set("display", display);
|
|
}
|
|
return {
|
|
...location,
|
|
search: params.toString(),
|
|
};
|
|
};
|
|
|
|
const DISPLAY_OPTIONS: Display[] = [null, "detail"];
|
|
return (
|
|
<div className="dropdown">
|
|
<button
|
|
className="btn btn-white btn-sm dropdown-toggle"
|
|
type="button"
|
|
onClick={toggle}
|
|
>
|
|
{display === "detail" ? "Detailed" : "Summary"}
|
|
</button>
|
|
<div className={`dropdown-menu-end dropdown-menu${show ? " show" : ""}`}>
|
|
{DISPLAY_OPTIONS.map((displayOption) => {
|
|
return (
|
|
<Link
|
|
key={displayOption || "null"}
|
|
to={(location) => buildLocation(location, displayOption)}
|
|
className={`dropdown-item${
|
|
displayOption === display ? " active" : ""
|
|
}`}
|
|
onClick={toggle}
|
|
>
|
|
{displayOption === "detail" ? "Detailed" : "Summary"}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|