diff --git a/CHANGELOG.md b/CHANGELOG.md index 764818930..737d453da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- [#415](https://github.com/alleslabs/celatone-frontend/pull/415) Search by icns names feature and Show registered icns names on account details page - [#438](https://github.com/alleslabs/celatone-frontend/pull/438) Add new home page - [#437](https://github.com/alleslabs/celatone-frontend/pull/437) Add first landing prompt for dev mode - [#436](https://github.com/alleslabs/celatone-frontend/pull/436) Implement merge navigation diff --git a/src/lib/app-provider/hooks/useBaseApiRoute.ts b/src/lib/app-provider/hooks/useBaseApiRoute.ts index 95d877f96..b9cf4862e 100644 --- a/src/lib/app-provider/hooks/useBaseApiRoute.ts +++ b/src/lib/app-provider/hooks/useBaseApiRoute.ts @@ -11,6 +11,8 @@ export const useBaseApiRoute = ( | "codes" | "accounts" | "rest" + | "icns_names" + | "icns_address" | "native_tokens" | "cosmwasm" ): string => { @@ -43,6 +45,10 @@ export const useBaseApiRoute = ( return `${api}/accounts/${chain}/${currentChainId}`; case "rest": return `${api}/rest/${chain}/${currentChainId}`; + case "icns_names": + return `${api}/icns/names`; + case "icns_address": + return `${api}/icns/address`; case "native_tokens": return `${api}/native-assets/${chain}/${currentChainId}`; case "cosmwasm": diff --git a/src/lib/components/CopyLink.tsx b/src/lib/components/CopyLink.tsx index c3de6c80c..16530a7c7 100644 --- a/src/lib/components/CopyLink.tsx +++ b/src/lib/components/CopyLink.tsx @@ -12,6 +12,7 @@ interface CopyLinkProps extends FlexProps { value: string; amptrackSection?: string; type: string; + withoutIcon?: boolean; showCopyOnHover?: boolean; } @@ -19,6 +20,7 @@ export const CopyLink = ({ value, amptrackSection, type, + withoutIcon, showCopyOnHover = false, ...flexProps }: CopyLinkProps) => { @@ -69,14 +71,16 @@ export const CopyLink = ({ > {value === address ? `${value} (Me)` : value} - + {!withoutIcon && ( + + )} ); diff --git a/src/lib/components/PrimaryNameMark.tsx b/src/lib/components/PrimaryNameMark.tsx new file mode 100644 index 000000000..8ad8a330a --- /dev/null +++ b/src/lib/components/PrimaryNameMark.tsx @@ -0,0 +1,10 @@ +import { CustomIcon } from "lib/components/icon"; +import { Tooltip } from "lib/components/Tooltip"; + +export const PrimaryNameMark = () => ( + +
+ +
+
+); diff --git a/src/lib/components/icon/CustomIcon.tsx b/src/lib/components/icon/CustomIcon.tsx index 20ff1fa0b..4d22b2739 100644 --- a/src/lib/components/icon/CustomIcon.tsx +++ b/src/lib/components/icon/CustomIcon.tsx @@ -1252,6 +1252,17 @@ export const ICONS = { ), viewBox: "0.5 1 16 16", }, + "star-solid": { + svg: ( + + ), + viewBox: "0 0 8 10", + }, "submit-proposal": { svg: ( ( const value = window.localStorage.getItem(key); return value ? (JSON.parse(value) as T) : defaultValue; } catch (e) { - console.warn(`Error reading localStorage key “${key}”:`, e); return defaultValue as T; } }); @@ -22,7 +21,7 @@ export const useLocalStorage = ( try { window.localStorage.setItem(key, JSON.stringify(storedValue)); } catch (e) { - console.warn(`Error setting localStorage key “${key}”:`, e); + // I want application to not crush, but don't care about the message } }, [key, storedValue]); diff --git a/src/lib/layout/Searchbar.tsx b/src/lib/layout/Searchbar.tsx index 5b2af0e96..6f7ff49fe 100644 --- a/src/lib/layout/Searchbar.tsx +++ b/src/lib/layout/Searchbar.tsx @@ -28,8 +28,12 @@ import { useMobile, } from "lib/app-provider"; import { CustomIcon } from "lib/components/icon"; +import { PrimaryNameMark } from "lib/components/PrimaryNameMark"; import { AmpTrackUseMainSearch } from "lib/services/amplitude"; -import type { SearchResultType } from "lib/services/searchService"; +import type { + ResultMetadata, + SearchResultType, +} from "lib/services/searchService"; import { useSearchHandler } from "lib/services/searchService"; import type { Option } from "lib/types"; @@ -48,6 +52,7 @@ interface ResultItemProps { type: SearchResultType; value: string; cursor: Option; + metadata: ResultMetadata; setCursor: (index: Option) => void; handleSelectResult: (type?: SearchResultType, isClick?: boolean) => void; onClose?: () => void; @@ -83,20 +88,20 @@ const ResultItem = ({ type, value, cursor, + metadata, setCursor, handleSelectResult, onClose, }: ResultItemProps) => { const route = getRouteOptions(type)?.pathname; - return ( {type} {route && ( - - {value} - + {metadata.icns.address || value} + {metadata.icns.icnsNames?.primary_name && ( + + + + + {metadata.icns.icnsNames.primary_name} + + + {value !== metadata.icns.address && + value !== metadata.icns.icnsNames?.primary_name && ( + + {value} + + )} + + )} + )} ); @@ -120,6 +150,7 @@ const ResultRender = ({ results, keyword, cursor, + metadata, setCursor, handleSelectResult, onClose, @@ -127,6 +158,7 @@ const ResultRender = ({ results: SearchResultType[]; keyword: string; cursor: Option; + metadata: ResultMetadata; setCursor: (index: Option) => void; handleSelectResult: (type?: SearchResultType, isClick?: boolean) => void; onClose?: () => void; @@ -146,6 +178,7 @@ const ResultRender = ({ type={type} value={keyword} cursor={cursor} + metadata={metadata} setCursor={setCursor} handleSelectResult={handleSelectResult} onClose={onClose} @@ -193,7 +226,7 @@ const Searchbar = () => { }, } = useCelatoneApp(); const navigate = useInternalNavigate(); - const { results, isLoading } = useSearchHandler(keyword, () => + const { results, isLoading, metadata } = useSearchHandler(keyword, () => setIsTyping(false) ); const boxRef = useRef(null); @@ -213,13 +246,13 @@ const Searchbar = () => { if (routeOptions) { navigate({ pathname: routeOptions.pathname, - query: { [routeOptions.query]: keyword }, + query: { [routeOptions.query]: metadata.icns.address || keyword }, }); setDisplayResults(false); setKeyword(""); } }, - [keyword, navigate] + [metadata.icns.address, keyword, navigate] ); const handleOnKeyEnter = useCallback( @@ -313,6 +346,7 @@ const Searchbar = () => { keyword={keyword} handleSelectResult={handleSelectResult} onClose={onClose} + metadata={metadata} /> )} @@ -374,10 +408,11 @@ const Searchbar = () => { ) : ( )} diff --git a/src/lib/pages/account-details/components/AccountHeader.tsx b/src/lib/pages/account-details/components/AccountHeader.tsx new file mode 100644 index 000000000..1d2d6a885 --- /dev/null +++ b/src/lib/pages/account-details/components/AccountHeader.tsx @@ -0,0 +1,90 @@ +import { Flex, Heading, Image, Text } from "@chakra-ui/react"; + +import { CopyLink } from "lib/components/CopyLink"; +import { CustomIcon } from "lib/components/icon"; +import { PrimaryNameMark } from "lib/components/PrimaryNameMark"; +import type { ICNSNamesResponse } from "lib/services/ns"; +import type { HumanAddr, Option, PublicDetail } from "lib/types"; + +interface AccounHeaderProps { + publicName: Option; + publicDetail: Option; + icnsName: Option; + accountAddress: HumanAddr; +} + +export const AccountHeader = ({ + publicName, + publicDetail, + icnsName, + accountAddress, +}: AccounHeaderProps) => { + const displayName = icnsName?.primary_name || "Account Details"; + + return ( + + + {publicDetail?.logo || icnsName?.primary_name ? ( + {publicDetail?.name + ) : ( + + )} + + {publicName ?? displayName} + + + + + Wallet Address: + + + + {icnsName?.primary_name && ( + + + Registered ICNS names: + + + {icnsName.names.map((name) => ( +
+ {name === icnsName.primary_name && } + +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/lib/pages/account-details/components/AccountTop.tsx b/src/lib/pages/account-details/components/AccountTop.tsx deleted file mode 100644 index c2550dfb3..000000000 --- a/src/lib/pages/account-details/components/AccountTop.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Flex, Image, Heading, Text } from "@chakra-ui/react"; - -import { Breadcrumb } from "lib/components/Breadcrumb"; -import { CopyLink } from "lib/components/CopyLink"; -import { CustomIcon } from "lib/components/icon"; -import type { HumanAddr, PublicDetail, PublicInfo } from "lib/types"; -import { truncate } from "lib/utils"; - -interface AccountTopProps { - accountAddress: HumanAddr; - publicDetail: PublicDetail | undefined; - displayName: string; - publicInfo: PublicInfo | undefined; -} -export const AccountTop = ({ - accountAddress, - publicDetail, - displayName, - publicInfo, -}: AccountTopProps) => { - return ( - <> - - {publicDetail && ( - - )} - - - {publicDetail?.logo && ( - {publicDetail.name} - )} - - {displayName} - - - - - Wallet Address: - - - - - {publicInfo?.description && ( - - - - - Public Account Description - - - - {publicInfo?.description} - - - )} - - ); -}; diff --git a/src/lib/pages/account-details/index.tsx b/src/lib/pages/account-details/index.tsx index c8b63c85d..fd1ca9c9b 100644 --- a/src/lib/pages/account-details/index.tsx +++ b/src/lib/pages/account-details/index.tsx @@ -1,22 +1,32 @@ -import { Flex, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; +import { + Flex, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, +} from "@chakra-ui/react"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { useValidateAddress, useWasmConfig } from "lib/app-provider"; +import { Breadcrumb } from "lib/components/Breadcrumb"; import { CustomTab } from "lib/components/CustomTab"; +import { CustomIcon } from "lib/components/icon"; import PageContainer from "lib/components/PageContainer"; import { InvalidState } from "lib/components/state"; import { useAccountDetailsTableCounts } from "lib/model/account"; import { useAccountId } from "lib/services/accountService"; import { AmpEvent, AmpTrack, AmpTrackUseTab } from "lib/services/amplitude"; +import { useICNSNamesByAddress } from "lib/services/nameService"; import { usePublicProjectByAccountAddress, usePublicProjectBySlug, } from "lib/services/publicProjectService"; import type { HumanAddr } from "lib/types"; -import { getFirstQueryParam, scrollToTop } from "lib/utils"; +import { getFirstQueryParam, scrollToTop, truncate } from "lib/utils"; -import { AccountTop } from "./components/AccountTop"; +import { AccountHeader } from "./components/AccountHeader"; import { AssetsSection } from "./components/asset"; import { DelegationsSection } from "./components/delegations"; import { @@ -53,6 +63,7 @@ const AccountDetailsBody = ({ accountAddress }: AccountDetailsBodyProps) => { const { data: publicInfo } = usePublicProjectByAccountAddress(accountAddress); const { data: publicInfoBySlug } = usePublicProjectBySlug(publicInfo?.slug); const { data: accountId } = useAccountId(accountAddress); + const { data: icnsName } = useICNSNamesByAddress(accountAddress); const publicDetail = publicInfoBySlug?.details; const { @@ -70,17 +81,53 @@ const AccountDetailsBody = ({ accountAddress }: AccountDetailsBodyProps) => { scrollToTop(); }; - const displayName = publicInfo?.name ?? "Account Details"; - return ( <> - - + + {publicDetail && ( + + )} + + + {publicInfo?.description && ( + + + + + Public Account Description + + + + {publicInfo?.description} + + + )} + + => { + const resolverEndpoint = useBaseApiRoute("icns_names"); + const getAddressType = useGetAddressType(); + const addressType = getAddressType(address); + + const queryFn = async ({ + queryKey, + }: QueryFunctionContext): Promise => { + const icnsNames = await queryICNSNamesByAddress( + queryKey[1], + queryKey[2] as Addr + ); + const primaryIndex = icnsNames.names.indexOf(icnsNames.primary_name); + if (primaryIndex > -1) { + icnsNames.names.splice(primaryIndex, 1); + icnsNames.names.unshift(icnsNames.primary_name); + } + return icnsNames; + }; + + return useQuery({ + queryKey: ["icns_names", resolverEndpoint, address], + queryFn, + refetchOnWindowFocus: false, + enabled: + addressType === "contract_address" || addressType === "user_address", + retry: 1, + }); +}; + +interface AddressByICNSInternal { + address: Addr; + addressType: AddressReturnType; +} + +export const useAddressByICNSName = ( + name: string +): UseQueryResult => { + const resolverEndpoint = useBaseApiRoute("icns_address"); + const lcdEndpoint = useBaseApiRoute("rest"); + const getAddressType = useGetAddressType(); + const { + chain: { bech32_prefix: bech32Prefix }, + } = useCurrentChain(); + + const queryFn = async ({ + queryKey, + }: QueryFunctionContext): Promise => { + // Strip bech32 prefix to allow searching with .prefix (e.g. example.osmo) + const [stripPrefixName] = queryKey[2].split(`.${bech32Prefix}`); + const icnsAddress = await queryAddressByICNSName( + queryKey[1], + stripPrefixName, + queryKey[3] + ); + let addressType = getAddressType(icnsAddress); + if (addressType === "contract_address") { + const contractData = await queryContract( + lcdEndpoint, + icnsAddress as ContractAddr + ); + if (!contractData) addressType = "user_address"; + } + return { + address: icnsAddress, + addressType, + }; + }; + + return useQuery({ + queryKey: ["icns_address", resolverEndpoint, name, bech32Prefix], + queryFn, + refetchOnWindowFocus: false, + enabled: Boolean(name), + retry: 1, + }); +}; diff --git a/src/lib/services/ns.ts b/src/lib/services/ns.ts new file mode 100644 index 000000000..a73358743 --- /dev/null +++ b/src/lib/services/ns.ts @@ -0,0 +1,27 @@ +import axios from "axios"; + +import type { Addr } from "lib/types"; + +export interface ICNSNamesResponse { + names: string[]; + primary_name: string; +} + +export const queryICNSNamesByAddress = async ( + baseEndpoint: string, + address: Addr +) => { + const { data } = await axios.get( + `${baseEndpoint}/${address}` + ); + return data; +}; + +export const queryAddressByICNSName = async ( + baseEndpoint: string, + name: string, + bech32Prefix: string +): Promise => { + const { data } = await axios.get(`${baseEndpoint}/${name}/${bech32Prefix}`); + return data.address; +}; diff --git a/src/lib/services/searchService.ts b/src/lib/services/searchService.ts index 9638f7a4b..232e76e8a 100644 --- a/src/lib/services/searchService.ts +++ b/src/lib/services/searchService.ts @@ -1,17 +1,19 @@ import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useBaseApiRoute, useCelatoneApp, useGetAddressType, } from "lib/app-provider"; -import type { ContractAddr } from "lib/types"; +import type { Addr, ContractAddr, Option } from "lib/types"; import { isBlock, isCodeId, isTxHash } from "lib/utils"; import { useBlockDetailsQuery } from "./blockService"; import { useCodeDataByCodeId } from "./codeService"; import { queryContract } from "./contract"; +import { useAddressByICNSName, useICNSNamesByAddress } from "./nameService"; +import type { ICNSNamesResponse } from "./ns"; import { useTxData } from "./txService"; export type SearchResultType = @@ -22,11 +24,50 @@ export type SearchResultType = | "Proposal ID" | "Block"; -// TODO: Add Proposal ID, ICNS query +export interface ResultMetadata { + icns: { icnsNames: Option; address: Option }; +} + +const resolveLoadingState = ({ + keyword, + txLoading, + codeLoading, + contractLoading, + blockLoading, + icnsAddressLoading, + isWasm, +}: { + keyword: string; + txLoading: boolean; + codeLoading: boolean; + contractLoading: boolean; + blockLoading: boolean; + icnsAddressLoading: boolean; + isWasm: boolean; +}) => { + const txDataLoading = isTxHash(keyword) && txLoading; + const codeDataLoading = isWasm && isCodeId(keyword) && codeLoading; + const contractDataLoading = isWasm && contractLoading; + const blockDataLoading = isBlock(keyword) && blockLoading; + + return ( + txDataLoading || + codeDataLoading || + contractDataLoading || + blockDataLoading || + icnsAddressLoading + ); +}; + +// TODO: Add Proposal ID export const useSearchHandler = ( keyword: string, resetHandlerStates: () => void -): { results: SearchResultType[]; isLoading: boolean } => { +): { + results: SearchResultType[]; + isLoading: boolean; + metadata: ResultMetadata; +} => { const [debouncedKeyword, setDebouncedKeyword] = useState(keyword); const { chainConfig: { @@ -54,13 +95,29 @@ export const useSearchHandler = ( ); const { data: blockData, isLoading: blockLoading } = useBlockDetailsQuery(debouncedKeyword); - const txDataLoading = isTxHash(debouncedKeyword) && txLoading; - const codeDataLoading = isWasm && isCodeId(debouncedKeyword) && codeLoading; - const contractDataLoading = isWasm && contractLoading; - const blockDataLoading = isBlock(debouncedKeyword) && blockLoading; + const { data: icnsAddressData, isLoading: icnsAddressLoading } = + useAddressByICNSName(debouncedKeyword); + const isAddr = addressType === "user_address" || addressType === "contract_address"; + // provide ICNS metadata result + const { data: icnsNames } = useICNSNamesByAddress( + (isAddr ? debouncedKeyword : icnsAddressData?.address) as Addr + ); + + const addressResult = useMemo(() => { + if (isAddr) { + return contractData ? "Contract Address" : "Wallet Address"; + } + return ( + icnsAddressData?.address && + (icnsAddressData.addressType === "contract_address" + ? "Contract Address" + : "Wallet Address") + ); + }, [isAddr, contractData, icnsAddressData]); + useEffect(() => { const timeoutId = setTimeout(() => { setDebouncedKeyword(keyword); @@ -71,15 +128,25 @@ export const useSearchHandler = ( return { results: [ - isAddr && (contractData ? "Contract Address" : "Wallet Address"), + addressResult, txData && "Transaction Hash", codeData && "Code ID", blockData && "Block", ].filter((res) => Boolean(res)) as SearchResultType[], - isLoading: - txDataLoading || - codeDataLoading || - contractDataLoading || - blockDataLoading, + isLoading: resolveLoadingState({ + keyword: debouncedKeyword, + txLoading, + codeLoading, + contractLoading, + blockLoading, + icnsAddressLoading, + isWasm, + }), + metadata: { + icns: { + icnsNames, + address: (isAddr ? debouncedKeyword : icnsAddressData?.address) as Addr, + }, + }, }; };