394 lines
10 KiB
TypeScript
394 lines
10 KiB
TypeScript
import React from "react";
|
|
import bs58 from "bs58";
|
|
import { useHistory, useLocation } from "react-router-dom";
|
|
import Select, { InputActionMeta, ActionMeta, ValueType } from "react-select";
|
|
import StateManager from "react-select";
|
|
import {
|
|
LOADER_IDS,
|
|
PROGRAM_INFO_BY_ID,
|
|
SPECIAL_IDS,
|
|
SYSVAR_IDS,
|
|
LoaderName,
|
|
} from "utils/tx";
|
|
import { Cluster, useCluster } from "providers/cluster";
|
|
import { useTokenRegistry } from "providers/mints/token-registry";
|
|
import { TokenInfoMap } from "@solana/spl-token-registry";
|
|
import { Connection } from "@solana/web3.js";
|
|
import { getDomainInfo, hasDomainSyntax } from "utils/name-service";
|
|
|
|
interface SearchOptions {
|
|
label: string;
|
|
options: {
|
|
label: string;
|
|
value: string[];
|
|
pathname: string;
|
|
}[];
|
|
}
|
|
|
|
export function SearchBar() {
|
|
const [search, setSearch] = React.useState("");
|
|
const searchRef = React.useRef("");
|
|
const [searchOptions, setSearchOptions] = React.useState<SearchOptions[]>([]);
|
|
const [loadingSearch, setLoadingSearch] = React.useState<boolean>(false);
|
|
const [loadingSearchMessage, setLoadingSearchMessage] =
|
|
React.useState<string>("loading...");
|
|
const selectRef = React.useRef<StateManager<any> | null>(null);
|
|
const history = useHistory();
|
|
const location = useLocation();
|
|
const { tokenRegistry } = useTokenRegistry();
|
|
const { url, cluster, clusterInfo } = useCluster();
|
|
|
|
const onChange = (
|
|
{ pathname }: ValueType<any, false>,
|
|
meta: ActionMeta<any>
|
|
) => {
|
|
if (meta.action === "select-option") {
|
|
history.push({ ...location, pathname });
|
|
setSearch("");
|
|
}
|
|
};
|
|
|
|
const onInputChange = (value: string, { action }: InputActionMeta) => {
|
|
if (action === "input-change") {
|
|
setSearch(value);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
searchRef.current = search;
|
|
setLoadingSearchMessage("Loading...");
|
|
setLoadingSearch(true);
|
|
|
|
// builds and sets local search output
|
|
const options = buildOptions(
|
|
search,
|
|
cluster,
|
|
tokenRegistry,
|
|
clusterInfo?.epochInfo.epoch
|
|
);
|
|
|
|
setSearchOptions(options);
|
|
|
|
// checking for non local search output
|
|
if (hasDomainSyntax(search)) {
|
|
// if search input is a potential domain we continue the loading state
|
|
domainSearch(options);
|
|
} else {
|
|
// if search input is not a potential domain we can conclude the search has finished
|
|
setLoadingSearch(false);
|
|
}
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [search]);
|
|
|
|
// appends domain lookup results to the local search state
|
|
const domainSearch = async (options: SearchOptions[]) => {
|
|
setLoadingSearchMessage("Looking up domain...");
|
|
const connection = new Connection(url);
|
|
const searchTerm = search;
|
|
const updatedOptions = await buildDomainOptions(
|
|
connection,
|
|
search,
|
|
options
|
|
);
|
|
if (searchRef.current === searchTerm) {
|
|
setSearchOptions(updatedOptions);
|
|
// after attempting to fetch the domain name we can conclude the loading state
|
|
setLoadingSearch(false);
|
|
setLoadingSearchMessage("Loading...");
|
|
}
|
|
};
|
|
|
|
const resetValue = "" as any;
|
|
return (
|
|
<div className="container my-4">
|
|
<div className="row align-items-center">
|
|
<div className="col">
|
|
<Select
|
|
autoFocus
|
|
ref={(ref) => (selectRef.current = ref)}
|
|
options={searchOptions}
|
|
noOptionsMessage={() => "No Results"}
|
|
loadingMessage={() => loadingSearchMessage}
|
|
placeholder="Search for blocks, accounts, transactions, programs, and tokens"
|
|
value={resetValue}
|
|
inputValue={search}
|
|
blurInputOnSelect
|
|
onMenuClose={() => selectRef.current?.blur()}
|
|
onChange={onChange}
|
|
styles={{
|
|
/* work around for https://github.com/JedWatson/react-select/issues/3857 */
|
|
placeholder: (style) => ({ ...style, pointerEvents: "none" }),
|
|
input: (style) => ({ ...style, width: "100%" }),
|
|
}}
|
|
onInputChange={onInputChange}
|
|
components={{ DropdownIndicator }}
|
|
classNamePrefix="search-bar"
|
|
isLoading={loadingSearch}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildProgramOptions(search: string, cluster: Cluster) {
|
|
const matchedPrograms = Object.entries(PROGRAM_INFO_BY_ID).filter(
|
|
([address, { name, deployments }]) => {
|
|
if (!deployments.includes(cluster)) return false;
|
|
return (
|
|
name.toLowerCase().includes(search.toLowerCase()) ||
|
|
address.includes(search)
|
|
);
|
|
}
|
|
);
|
|
|
|
if (matchedPrograms.length > 0) {
|
|
return {
|
|
label: "Programs",
|
|
options: matchedPrograms.map(([address, { name }]) => ({
|
|
label: name,
|
|
value: [name, address],
|
|
pathname: "/address/" + address,
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
const SEARCHABLE_LOADERS: LoaderName[] = [
|
|
"BPF Loader",
|
|
"BPF Loader 2",
|
|
"BPF Upgradeable Loader",
|
|
];
|
|
|
|
function buildLoaderOptions(search: string) {
|
|
const matchedLoaders = Object.entries(LOADER_IDS).filter(
|
|
([address, name]) => {
|
|
return (
|
|
SEARCHABLE_LOADERS.includes(name) &&
|
|
(name.toLowerCase().includes(search.toLowerCase()) ||
|
|
address.includes(search))
|
|
);
|
|
}
|
|
);
|
|
|
|
if (matchedLoaders.length > 0) {
|
|
return {
|
|
label: "Program Loaders",
|
|
options: matchedLoaders.map(([id, name]) => ({
|
|
label: name,
|
|
value: [name, id],
|
|
pathname: "/address/" + id,
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildSysvarOptions(search: string) {
|
|
const matchedSysvars = Object.entries(SYSVAR_IDS).filter(
|
|
([address, name]) => {
|
|
return (
|
|
name.toLowerCase().includes(search.toLowerCase()) ||
|
|
address.includes(search)
|
|
);
|
|
}
|
|
);
|
|
|
|
if (matchedSysvars.length > 0) {
|
|
return {
|
|
label: "Sysvars",
|
|
options: matchedSysvars.map(([id, name]) => ({
|
|
label: name,
|
|
value: [name, id],
|
|
pathname: "/address/" + id,
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildSpecialOptions(search: string) {
|
|
const matchedSpecialIds = Object.entries(SPECIAL_IDS).filter(
|
|
([address, name]) => {
|
|
return (
|
|
name.toLowerCase().includes(search.toLowerCase()) ||
|
|
address.includes(search)
|
|
);
|
|
}
|
|
);
|
|
|
|
if (matchedSpecialIds.length > 0) {
|
|
return {
|
|
label: "Accounts",
|
|
options: matchedSpecialIds.map(([id, name]) => ({
|
|
label: name,
|
|
value: [name, id],
|
|
pathname: "/address/" + id,
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildTokenOptions(
|
|
search: string,
|
|
cluster: Cluster,
|
|
tokenRegistry: TokenInfoMap
|
|
) {
|
|
const matchedTokens = Array.from(tokenRegistry.entries()).filter(
|
|
([address, details]) => {
|
|
const searchLower = search.toLowerCase();
|
|
return (
|
|
details.name.toLowerCase().includes(searchLower) ||
|
|
details.symbol.toLowerCase().includes(searchLower) ||
|
|
address.includes(search)
|
|
);
|
|
}
|
|
);
|
|
|
|
if (matchedTokens.length > 0) {
|
|
return {
|
|
label: "Tokens",
|
|
options: matchedTokens.slice(0, 10).map(([id, details]) => ({
|
|
label: details.name,
|
|
value: [details.name, details.symbol, id],
|
|
pathname: "/address/" + id,
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function buildDomainOptions(
|
|
connection: Connection,
|
|
search: string,
|
|
options: SearchOptions[]
|
|
) {
|
|
const domainInfo = await getDomainInfo(search, connection);
|
|
const updatedOptions: SearchOptions[] = [...options];
|
|
if (domainInfo && domainInfo.owner && domainInfo.address) {
|
|
updatedOptions.push({
|
|
label: "Domain Owner",
|
|
options: [
|
|
{
|
|
label: domainInfo.owner,
|
|
value: [search],
|
|
pathname: "/address/" + domainInfo.owner,
|
|
},
|
|
],
|
|
});
|
|
updatedOptions.push({
|
|
label: "Name Service Account",
|
|
options: [
|
|
{
|
|
label: search,
|
|
value: [search],
|
|
pathname: "/address/" + domainInfo.address,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return updatedOptions;
|
|
}
|
|
|
|
// builds local search options
|
|
function buildOptions(
|
|
rawSearch: string,
|
|
cluster: Cluster,
|
|
tokenRegistry: TokenInfoMap,
|
|
currentEpoch?: number
|
|
) {
|
|
const search = rawSearch.trim();
|
|
if (search.length === 0) return [];
|
|
|
|
const options = [];
|
|
|
|
const programOptions = buildProgramOptions(search, cluster);
|
|
if (programOptions) {
|
|
options.push(programOptions);
|
|
}
|
|
|
|
const loaderOptions = buildLoaderOptions(search);
|
|
if (loaderOptions) {
|
|
options.push(loaderOptions);
|
|
}
|
|
|
|
const sysvarOptions = buildSysvarOptions(search);
|
|
if (sysvarOptions) {
|
|
options.push(sysvarOptions);
|
|
}
|
|
|
|
const specialOptions = buildSpecialOptions(search);
|
|
if (specialOptions) {
|
|
options.push(specialOptions);
|
|
}
|
|
|
|
const tokenOptions = buildTokenOptions(search, cluster, tokenRegistry);
|
|
if (tokenOptions) {
|
|
options.push(tokenOptions);
|
|
}
|
|
|
|
if (!isNaN(Number(search))) {
|
|
options.push({
|
|
label: "Block",
|
|
options: [
|
|
{
|
|
label: `Slot #${search}`,
|
|
value: [search],
|
|
pathname: `/block/${search}`,
|
|
},
|
|
],
|
|
});
|
|
|
|
if (currentEpoch !== undefined && Number(search) <= currentEpoch + 1) {
|
|
options.push({
|
|
label: "Epoch",
|
|
options: [
|
|
{
|
|
label: `Epoch #${search}`,
|
|
value: [search],
|
|
pathname: `/epoch/${search}`,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Prefer nice suggestions over raw suggestions
|
|
if (options.length > 0) return options;
|
|
|
|
try {
|
|
const decoded = bs58.decode(search);
|
|
if (decoded.length === 32) {
|
|
options.push({
|
|
label: "Account",
|
|
options: [
|
|
{
|
|
label: search,
|
|
value: [search],
|
|
pathname: "/address/" + search,
|
|
},
|
|
],
|
|
});
|
|
} else if (decoded.length === 64) {
|
|
options.push({
|
|
label: "Transaction",
|
|
options: [
|
|
{
|
|
label: search,
|
|
value: [search],
|
|
pathname: "/tx/" + search,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
} catch (err) {}
|
|
|
|
return options;
|
|
}
|
|
|
|
function DropdownIndicator() {
|
|
return (
|
|
<div className="search-indicator">
|
|
<span className="fe fe-search"></span>
|
|
</div>
|
|
);
|
|
}
|