diff --git a/public/locales/en.json b/public/locales/en.json index e79073c..48925a6 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -13,7 +13,24 @@ "search": { "placeholder": "Search the Krist network", "rateLimitHit": "Please slow down.", - "noResults": "No results." + "noResults": "No results.", + + "resultAddress": "Address", + "resultName": "Name", + "resultNameOwner": "Owned by <1 />", + "resultBlockID": "Block ID", + "resultBlockIDMinedBy": "Mined by <1 />", + "resultTransactionID": "Transaction ID", + "resultTransactions": "Transactions", + "resultTransactionsAddress": "Search for transactions involving <1 />", + "resultTransactionsAddressResult": "<1>{{count}} transaction involving <3 />", + "resultTransactionsAddressResult_plural": "<1>{{count}} transactions involving <3 />", + "resultTransactionsName": "Search for transactions involving <1 />", + "resultTransactionsNameResult": "<1>{{count}} transaction sent to <3 />", + "resultTransactionsNameResult_plural": "<1>{{count}} transactions sent to <3 />", + "resultTransactionsMetadata": "Searching for metadata containing <1 />", + "resultTransactionsMetadataResult": "<1>{{count}} transaction with metadata containing <3 />", + "resultTransactionsMetadataResult_plural": "<1>{{count}} transactions with metadata containing <3 />" }, "send": "Send", diff --git a/src/components/ContextualAddress.tsx b/src/components/ContextualAddress.tsx index 87c560a..39e203e 100644 --- a/src/components/ContextualAddress.tsx +++ b/src/components/ContextualAddress.tsx @@ -14,7 +14,7 @@ import { parseCommonMeta, CommonMeta } from "../utils/commonmeta"; import { stripNameSuffix } from "../utils/currency"; -import { KristName } from "./KristName"; +import { KristNameLink } from "./KristNameLink"; import "./ContextualAddress.less"; @@ -44,7 +44,7 @@ ? <> {/* Display the name/metaname (e.g. foo@bar.kst) */} {metaname && <>{metaname}@} - + {/* Display the original address too */} {!hideNameAddress && <> diff --git a/src/components/DateTime.tsx b/src/components/DateTime.tsx index 4cedf79..f1433e4 100644 --- a/src/components/DateTime.tsx +++ b/src/components/DateTime.tsx @@ -2,19 +2,23 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React from "react"; +import classNames from "classnames"; import { Tooltip } from "antd"; import dayjs from "dayjs"; -interface Props { +interface OwnProps { date?: Date | string | null; } +type Props = React.HTMLProps & OwnProps; -export function DateTime({ date }: Props): JSX.Element | null { +export function DateTime({ date, ...props }: Props): JSX.Element | null { if (!date) return null; const realDate = typeof date === "string" ? new Date(date) : date; return - {dayjs(realDate).format("YYYY-MM-DD HH:mm:ss")} + + {dayjs(realDate).format("YYYY-MM-DD HH:mm:ss")} + ; } diff --git a/src/components/KristName.tsx b/src/components/KristName.tsx deleted file mode 100644 index 7405114..0000000 --- a/src/components/KristName.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2020-2021 Drew Lemmy -// This file is part of KristWeb 2 under GPL-3.0. -// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import React from "react"; - -import { useSelector } from "react-redux"; -import { RootState } from "../store"; - -import { Link } from "react-router-dom"; - -interface Props { - name: string; - className?: string; -} - -export function KristName({ name, className }: Props): JSX.Element { - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); - - return - - {name}.{nameSuffix} - - ; -} diff --git a/src/components/KristNameLink.tsx b/src/components/KristNameLink.tsx new file mode 100644 index 0000000..7faf1e6 --- /dev/null +++ b/src/components/KristNameLink.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React from "react"; +import classNames from "classnames"; + +import { useSelector } from "react-redux"; +import { RootState } from "../store"; + +import { Link } from "react-router-dom"; + +interface OwnProps { + name: string; + noLink?: boolean; +} +type Props = React.HTMLProps & OwnProps; + +export function KristNameLink({ name, noLink, ...props }: Props): JSX.Element { + const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + + const contents = `${name}.${nameSuffix}`; + + return + {noLink + ? contents + : {contents}} + ; +} diff --git a/src/components/KristValue.tsx b/src/components/KristValue.tsx index f586276..763b45b 100644 --- a/src/components/KristValue.tsx +++ b/src/components/KristValue.tsx @@ -18,7 +18,6 @@ green?: boolean; highlightZero?: boolean; }; - type Props = React.HTMLProps & OwnProps; export const KristValue = ({ value, long, hideNullish, green, highlightZero, ...props }: Props): JSX.Element | null => { diff --git a/src/krist/api/search.ts b/src/krist/api/search.ts index 525d13a..a346d56 100644 --- a/src/krist/api/search.ts +++ b/src/krist/api/search.ts @@ -17,10 +17,10 @@ query: SearchQueryMatch; matches: { - exactAddress: KristAddress | boolean; - exactName: KristName | boolean; - exactBlock: KristBlock | boolean; - exactTransaction: KristTransaction | boolean; + exactAddress: KristAddress | false; + exactName: KristName | false; + exactBlock: KristBlock | false; + exactTransaction: KristTransaction | false; }; } @@ -29,9 +29,9 @@ matches: { transactions: { - addressInvolved: number | boolean; - nameInvolved: number | boolean; - metadata: number | boolean; + addressInvolved: number | false; + nameInvolved: number | false; + metadata: number | false; }; }; } diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx index f4fcada..c3343b4 100644 --- a/src/layout/nav/Search.tsx +++ b/src/layout/nav/Search.tsx @@ -2,20 +2,20 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useState, useMemo, useRef, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react"; -import { AutoComplete, Input, Typography, Spin } from "antd"; +import { AutoComplete, Input } from "antd"; import { useTranslation } from "react-i18next"; import { RateLimitError } from "../../krist/api"; -import { SearchResult, search, searchExtended } from "../../krist/api/search"; +import { SearchResult, search, searchExtended, SearchExtendedResult } from "../../krist/api/search"; import { throttle, debounce } from "lodash-es"; import LRU from "lru-cache"; +import * as SearchResults from "./SearchResults"; + import Debug from "debug"; const debug = Debug("kristweb:search"); -const { Text } = Typography; - const SEARCH_THROTTLE = 500; const SEARCH_RATE_LIMIT_WAIT = 5000; @@ -23,31 +23,24 @@ query: string, waitingForRef: MutableRefObject, setResults: (query: string, results: SearchResult | undefined) => void, - setRateLimitHit: Dispatch> + setExtendedResults: (query: string, results: SearchExtendedResult | undefined) => void, + onRateLimitHit: () => void ) { debug("performing search for %s", query); - // Store the most recent search query so that the results don't arrive - // out of order. + // Store the most recent search query so that the results don't arrive out of + // order. waitingForRef.current = query; try { - const results = await search(query); - setResults(query, results); + await Promise.all([ + search(query).then(r => setResults(query, r)), + searchExtended(query).then(r => setExtendedResults(query, r)), + ]); } catch (err) { // Most likely error is `rate_limit_hit`: - if (err instanceof RateLimitError) { - // Lyqydate the search input and wait 5 seconds before unlocking it - debug("rate limit hit, locking input for 5 seconds"); - setRateLimitHit(true); - - setTimeout(() => { - debug("unlocking input"); - setRateLimitHit(false); - }, SEARCH_RATE_LIMIT_WAIT); - } else { - console.error(err); - } + if (err instanceof RateLimitError) onRateLimitHit(); + else console.error(err); } } @@ -56,6 +49,7 @@ const [value, setValue] = useState(""); const [results, setResults] = useState(); + const [extendedResults, setExtendedResults] = useState(); const [loading, setLoading] = useState(false); const [rateLimitHit, setRateLimitHit] = useState(false); @@ -71,36 +65,61 @@ // The cache is cleared each time the search is focused to keep the results // fresh. const searchCache = useMemo(() => new LRU({ max: 100, maxAge : 300000 }), []); + const searchExtendedCache = useMemo(() => new LRU({ max: 100, maxAge : 300000 }), []); - function cachedSetResults(query: string, results: SearchResult | undefined) { - // Cowardly refuse to perform any search if the rate limit was hit - if (!results || rateLimitHit) return setResults(undefined); + // Create a function to set the results for a given result type + const cachedSetResultsBase = + (cache: LRU, setResultsFn: Dispatch>) => + (query: string, results: T | undefined) => { + // Cowardly refuse to perform any search if the rate limit was hit + if (!results || rateLimitHit) return setResultsFn(undefined); - // If this result isn't for the most recent search query (i.e. it arrived - // out of order), ignore it - if (query !== waitingForRef.current) { - debug("ignoring out of order query %s (we need %s)", query, waitingForRef.current); - return; - } + // If this result isn't for the most recent search query (i.e. it + // arrived out of order), ignore it + if (query !== waitingForRef.current) { + debug("ignoring out of order query %s (we need %s)", query, waitingForRef.current); + return; + } - searchCache.set(query, results); - setResults(results); - setLoading(false); + cache.set(query, results); + setResultsFn(results); + setLoading(false); + }; + + const cachedSetResults = cachedSetResultsBase(searchCache, setResults); + const cachedSetExtendedResults = cachedSetResultsBase(searchExtendedCache, setExtendedResults); + + function onRateLimitHit() { + // Ignore repeated rate limit errors + if (rateLimitHit) return; + + // Lyqydate the search input and wait 5 seconds before unlocking it + debug("rate limit hit, locking input for 5 seconds"); + setRateLimitHit(true); + + setTimeout(() => { + debug("unlocking input"); + setRateLimitHit(false); + }, SEARCH_RATE_LIMIT_WAIT); } function onSearch(query: string) { + debug("query: %s", query); + // Cowardly refuse to perform any search if the rate limit was hit if (rateLimitHit) return; const cleanQuery = query.trim(); if (!cleanQuery) { + setResults(undefined); setLoading(false); - return setResults(undefined); + return; } // Use the search cache if possible, to avoid unnecessary network requests const cached = searchCache.get(cleanQuery); - if (cached) { + const cachedExtended = searchExtendedCache.get(cleanQuery); + if (cached || cachedExtended) { debug("using cached result for %s", query); // Ensure that an out of order request doesn't overwrite our cached result @@ -110,7 +129,9 @@ throttledAutocomplete.cancel(); debouncedAutocomplete.cancel(); - setResults(cached); + if (cached) setResults(cached); + if (cachedExtended) setExtendedResults(cachedExtended); + setLoading(false); return; } @@ -122,40 +143,50 @@ // Eagerly use `throttle` for short inputs, and patiently use `debounce` for // longer inputs. const fn = cleanQuery.length < 5 ? throttledAutocomplete : debouncedAutocomplete; - fn(cleanQuery, waitingForRef, cachedSetResults, setRateLimitHit); + fn(cleanQuery, waitingForRef, cachedSetResults, cachedSetExtendedResults, onRateLimitHit); } + const staticResult = (value: string, label: ReactNode) => [{ value, label }]; + function renderResults(): { value: string; label: ReactNode }[] { + debug("current state: %b %b %b %b", rateLimitHit, !value.trim(), loading, results); + // Show a warning instead of the results if the rate limit was hit - if (rateLimitHit) { - return [{ - value: "rate_limit_hit", - label: {t("nav.search.rateLimitHit")} - }]; - } - + if (rateLimitHit) return staticResult("rateLimitHit", ); // Don't return anything if there's no query at all - const cleanQuery = value.trim(); - if (!cleanQuery) return []; - + if (!value.trim()) return []; // Loading spinner, only if we don't already have some results - if (loading && !results) { - return [{ value: "loading", label: ( -
- -
- )}]; - } - + if (loading && !results) return staticResult("loading", ); // No results placeholder - if (!loading && !results) { - return [{ - value: "no_results", - label: {t("nav.search.noResults")} - }]; + if (!loading && !results) return staticResult("noResults", ); + + const options = []; + + if (results) { + const { exactAddress, exactName, exactBlock, exactTransaction } = results.matches; + + if (exactAddress) options.push({ + value: "address-" + exactAddress.address, + label: + }); + + if (exactName) options.push({ + value: "name-" + exactName.name, + label: + }); + + if (exactBlock) options.push({ + value: "block-" + exactBlock.height, + label: + }); + + if (exactTransaction) options.push({ + value: "transaction-" + exactTransaction.id, + label: + }); } - return []; + return options; } return
diff --git a/src/layout/nav/SearchResults.less b/src/layout/nav/SearchResults.less new file mode 100644 index 0000000..6af8285 --- /dev/null +++ b/src/layout/nav/SearchResults.less @@ -0,0 +1,53 @@ +@import (reference) "../../App.less"; + +.search-result-loading { + display: flex; + justify-content: center; + padding-top: 6px; +} + +.search-result { + display: flex; + + .search-result-type { + display: block; + + color: @text-color-secondary; + font-size: @font-size-sm; + font-weight: bold; + text-transform: uppercase; + } + + .search-result-value { + font-weight: bold; + } + + .result-left { + flex: 1; + } + + .result-right { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + + flex: 0; + + text-align: right; + + .krist-value { + font-size: @font-size-lg; + } + + .search-name-owner, .search-block-miner { + display: block; + color: @text-color-secondary; + } + + .date-time { + color: @text-color-secondary; + font-size: 90%; + } + } +} diff --git a/src/layout/nav/SearchResults.tsx b/src/layout/nav/SearchResults.tsx new file mode 100644 index 0000000..95aae4d --- /dev/null +++ b/src/layout/nav/SearchResults.tsx @@ -0,0 +1,115 @@ +import React, { ReactNode } from "react"; +import { Typography, Spin } from "antd"; + +import { Trans, useTranslation } from "react-i18next"; + +import { KristAddress, KristName, KristBlock, KristTransaction } from "../../krist/api/types"; +import { KristValue } from "../../components/KristValue"; +import { KristNameLink } from "../../components/KristNameLink"; +import { DateTime } from "../../components/DateTime"; + +import "./SearchResults.less"; + +const { Text } = Typography; + +export function Loading(): JSX.Element { + return
; +} + +export function NoResults(): JSX.Element { + const { t } = useTranslation(); + return {t("nav.search.noResults")}; +} + +export function RateLimitHit(): JSX.Element { + const { t } = useTranslation(); + return {t("nav.search.rateLimitHit")}; +} + +interface ExactMatchBaseProps { + typeKey: string; + primaryValue: ReactNode | number; + extraInfo?: ReactNode; +} +export function ExactMatchBase({ typeKey, primaryValue, extraInfo }: ExactMatchBaseProps): JSX.Element { + const { t } = useTranslation(); + + return
+
+ {/* Result type (e.g. 'Address', 'Transaction') */} + + {t(typeKey)} + + + {/* Primary result value (e.g. the address, the ID) */} + + {typeof primaryValue === "number" + ? primaryValue.toLocaleString() + : primaryValue} + +
+ + {extraInfo &&
+ {extraInfo} +
} +
; +} + +export function ExactAddressMatch({ address }: { address: KristAddress }): JSX.Element { + return } + />; +} + +export function ExactNameMatch({ name }: { name: KristName }): JSX.Element { + const { t } = useTranslation(); + + function Owner() { + return {name.owner}; + } + + return } + extraInfo={ + + Owned by + + } + />; +} + +export function ExactBlockMatch({ block }: { block: KristBlock }): JSX.Element { + const { t } = useTranslation(); + + function Miner() { + return {block.address}; + } + + return + + + Mined by + + + + + } + />; +} + +export function ExactTransactionMatch({ transaction }: { transaction: KristTransaction }): JSX.Element { + return + + + } + />; +} diff --git a/src/pages/dashboard/TransactionItem.tsx b/src/pages/dashboard/TransactionItem.tsx index bf6a015..2275062 100644 --- a/src/pages/dashboard/TransactionItem.tsx +++ b/src/pages/dashboard/TransactionItem.tsx @@ -12,7 +12,7 @@ import { KristTransaction } from "../../krist/api/types"; import { Wallet } from "../../krist/wallets/Wallet"; import { KristValue } from "../../components/KristValue"; -import { KristName } from "../../components/KristName"; +import { KristNameLink } from "../../components/KristNameLink"; import { ContextualAddress } from "../../components/ContextualAddress"; type InternalTxType = "transferred" | "sent" | "received" | "mined" | @@ -106,7 +106,7 @@ {(type === "name_a_record" || type === "name_purchased") && ( Name: - + )} @@ -156,7 +156,7 @@ ) : tx.type === "name_transfer" && ( // Transaction name - + )} ;