diff --git a/.vscode/settings.json b/.vscode/settings.json index 1546602..7b088fe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "KRISTWALLETEXTENSION", "Lemmy", "Lngs", + "Lyqydate", "Mutex", "Popconfirm", "Sider", diff --git a/package.json b/package.json index 84bc2ee..34fde37 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "i18next-browser-languagedetector": "^6.0.1", "i18next-http-backend": "^1.0.20", "lodash-es": "^4.17.21", + "lru-cache": "^6.0.0", "react": "^17.0.1", "react-chartjs-2": "^2.11.1", "react-dom": "^17.0.1", @@ -79,6 +80,7 @@ "@types/file-saver": "^2.0.1", "@types/jest": "^26.0.20", "@types/lodash-es": "^4.17.4", + "@types/lru-cache": "^5.1.0", "@types/node": "^12.19.16", "@types/react": "^17.0.1", "@types/react-dom": "^17.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05beee..8595b52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ i18next-browser-languagedetector: 6.0.1 i18next-http-backend: 1.1.0 lodash-es: 4.17.21 + lru-cache: 6.0.0 react: 17.0.1 react-chartjs-2: 2.11.1_6c446a34f83b2a92e3214f8b711c141a react-dom: 17.0.1_react@17.0.1 @@ -38,6 +39,7 @@ '@types/file-saver': 2.0.1 '@types/jest': 26.0.20 '@types/lodash-es': 4.17.4 + '@types/lru-cache': 5.1.0 '@types/node': 12.20.0 '@types/react': 17.0.2 '@types/react-dom': 17.0.1 @@ -2113,6 +2115,10 @@ dev: true resolution: integrity: sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== + /@types/lru-cache/5.1.0: + dev: true + resolution: + integrity: sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== /@types/minimatch/3.0.3: dev: true resolution: @@ -13632,6 +13638,7 @@ '@types/file-saver': ^2.0.1 '@types/jest': ^26.0.20 '@types/lodash-es': ^4.17.4 + '@types/lru-cache': ^5.1.0 '@types/node': ^12.19.16 '@types/react': ^17.0.1 '@types/react-dom': ^17.0.0 @@ -13664,6 +13671,7 @@ i18next-browser-languagedetector: ^6.0.1 i18next-http-backend: ^1.0.20 lodash-es: ^4.17.21 + lru-cache: ^6.0.0 prettier: ^2.2.1 react: ^17.0.1 react-chartjs-2: ^2.11.1 diff --git a/public/locales/en.json b/public/locales/en.json index 23c74b4..e79073c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -10,7 +10,11 @@ "connecting": "Connecting" }, - "search": "Search the Krist network", + "search": { + "placeholder": "Search the Krist network", + "rateLimitHit": "Please slow down.", + "noResults": "No results." + }, "send": "Send", "request": "Request", diff --git a/src/components/ws/SyncMOTD.tsx b/src/components/ws/SyncMOTD.tsx index 9e39446..761d532 100644 --- a/src/components/ws/SyncMOTD.tsx +++ b/src/components/ws/SyncMOTD.tsx @@ -9,7 +9,7 @@ import { store } from "../../App"; -import * as api from "../../krist/api/api"; +import * as api from "../../krist/api"; import { KristMOTD } from "../../krist/api/types"; import { recalculateWallets } from "../../krist/wallets/Wallet"; diff --git a/src/components/ws/SyncWork.tsx b/src/components/ws/SyncWork.tsx index 5fd4b68..c488570 100644 --- a/src/components/ws/SyncWork.tsx +++ b/src/components/ws/SyncWork.tsx @@ -9,7 +9,7 @@ import { store } from "../../App"; -import * as api from "../../krist/api/api"; +import * as api from "../../krist/api"; import { KristWorkDetailed } from "../../krist/api/types"; import Debug from "debug"; diff --git a/src/components/ws/WebsocketService.tsx b/src/components/ws/WebsocketService.tsx index 4862149..ff5eef1 100644 --- a/src/components/ws/WebsocketService.tsx +++ b/src/components/ws/WebsocketService.tsx @@ -10,7 +10,7 @@ import * as wsActions from "../../store/actions/WebsocketActions"; import * as nodeActions from "../../store/actions/NodeActions"; -import * as api from "../../krist/api/api"; +import * as api from "../../krist/api"; import { KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "../../krist/api/types"; import { findWalletByAddress, syncWallet, syncWalletUpdate } from "../../krist/wallets/Wallet"; import WebSocketAsPromised from "websocket-as-promised"; diff --git a/src/krist/api/api.ts b/src/krist/api/api.ts deleted file mode 100644 index 9a43a9f..0000000 --- a/src/krist/api/api.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { notification } from "antd"; -import i18n from "../../utils/i18n"; - -import { store } from "../../App"; - -import { APIResponse } from "./types"; -import { throttle } from "lodash-es"; - -export class APIError extends Error { - constructor(message: string, public parameter?: string) { - super(message); - } -} - -// Realistically, the only situation in which a rate limit will actually be hit -// by KristWeb is if an infinite loop is introduced (e.g. via useEffect), so we -// would want to avoid spamming notifications and making the performance bug -// worse, therefore this notification is throttled to 5 seconds. -const _notifyRateLimit = () => - notification.error({ message: i18n.t("rateLimitTitle"), description: i18n.t("rateLimitDescription") }); -const notifyRateLimit = throttle(_notifyRateLimit, 5000); - -export async function request(method: string, endpoint: string, options?: RequestInit): Promise> { - const syncNode = store.getState().node.syncNode; - - // Let the fetch bubble its error upwards - const res = await fetch(syncNode + "/" + endpoint, { - method, - ...options - }); - - if (res.status === 429) { - notifyRateLimit(); - throw new APIError("rate_limit_hit"); - } - - const data: APIResponse = await res.json(); - if (!data.ok || data.error) - throw new APIError(data.error || "unknown_error", data.parameter); - - return data; -} - -export const get = (endpoint: string, options?: RequestInit): Promise> => - request("GET", endpoint, options); -export const post = (endpoint: string, options?: RequestInit): Promise> => - request("POST", endpoint, options); diff --git a/src/krist/api/index.ts b/src/krist/api/index.ts new file mode 100644 index 0000000..c671cf1 --- /dev/null +++ b/src/krist/api/index.ts @@ -0,0 +1,57 @@ +import { notification } from "antd"; +import i18n from "../../utils/i18n"; + +import { store } from "../../App"; + +import { APIResponse } from "./types"; +import { throttle } from "lodash-es"; + +export class APIError extends Error { + constructor(message: string, public parameter?: string) { + super(message); + } +} + +export class RateLimitError extends APIError { + constructor() { super("rate_limit_hit"); } +} + +// Realistically, the only situation in which a rate limit will actually be hit +// by KristWeb is if an infinite loop is introduced (e.g. via useEffect), so we +// would want to avoid spamming notifications and making the performance bug +// worse, therefore this notification is throttled to 5 seconds. +const _notifyRateLimit = () => + notification.error({ message: i18n.t("rateLimitTitle"), description: i18n.t("rateLimitDescription") }); +const notifyRateLimit = throttle(_notifyRateLimit, 5000); + +interface RequestOptions extends RequestInit { + /** Suppresses the notification for a rate limited request. An error will + * still be thrown. */ + ignoreRateLimit?: boolean; +} + +export async function request(method: string, endpoint: string, options?: RequestOptions): Promise> { + const syncNode = store.getState().node.syncNode; + + // Let the fetch bubble its error upwards + const res = await fetch(syncNode + "/" + endpoint, { + method, + ...options + }); + + if (res.status === 429) { + if (!options?.ignoreRateLimit) notifyRateLimit(); + throw new RateLimitError(); + } + + const data: APIResponse = await res.json(); + if (!data.ok || data.error) + throw new APIError(data.error || "unknown_error", data.parameter); + + return data; +} + +export const get = (endpoint: string, options?: RequestOptions): Promise> => + request("GET", endpoint, options); +export const post = (endpoint: string, options?: RequestOptions): Promise> => + request("POST", endpoint, options); diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts index f94d138..58fe729 100644 --- a/src/krist/api/lookup.ts +++ b/src/krist/api/lookup.ts @@ -2,7 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { KristAddress, KristTransaction } from "./types"; -import * as api from "./api"; +import * as api from "."; interface LookupAddressesResponse { found: number; diff --git a/src/krist/api/search.ts b/src/krist/api/search.ts index 6ce8399..525d13a 100644 --- a/src/krist/api/search.ts +++ b/src/krist/api/search.ts @@ -2,9 +2,10 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { KristAddress, KristBlock, KristName, KristTransaction } from "./types"; -import * as api from "./api"; +import * as api from "."; -interface SearchQueryMatch { +export interface SearchQueryMatch { + originalQuery: string; matchedAddress: boolean; matchedName: boolean; matchedBlock: boolean; @@ -12,7 +13,7 @@ strippedName: string; } -interface SearchResult { +export interface SearchResult { query: SearchQueryMatch; matches: { @@ -23,7 +24,7 @@ }; } -interface SearchExtendedResult { +export interface SearchExtendedResult { query: SearchQueryMatch; matches: { @@ -37,10 +38,24 @@ export async function search(query?: string): Promise { if (!query) return; - return api.get("search?q=" + encodeURIComponent(query)); + + return api.get( + "search?q=" + encodeURIComponent(query), + + // Don't show the rate limit notification if it is hit, a message will be + // shown in the search box instead + { ignoreRateLimit: true } + ); } export async function searchExtended(query?: string): Promise { if (!query || query.length < 3) return; - return api.get("search/extended?q=" + encodeURIComponent(query)); + + return api.get( + "search/extended?q=" + encodeURIComponent(query), + + // Don't show the rate limit notification if it is hit, a message will be + // shown in the search box instead + { ignoreRateLimit: true } + ); } diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx index 9b62367..f4fcada 100644 --- a/src/layout/nav/Search.tsx +++ b/src/layout/nav/Search.tsx @@ -1,30 +1,186 @@ // 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, { useState } from "react"; -import { AutoComplete, Input } from "antd"; +import React, { useState, useMemo, useRef, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react"; +import { AutoComplete, Input, Typography, Spin } from "antd"; import { useTranslation } from "react-i18next"; -import { throttle } from "lodash-es"; +import { RateLimitError } from "../../krist/api"; +import { SearchResult, search, searchExtended } from "../../krist/api/search"; +import { throttle, debounce } from "lodash-es"; +import LRU from "lru-cache"; import Debug from "debug"; -const debug = Debug("kristweb:transactions-card"); +const debug = Debug("kristweb:search"); + +const { Text } = Typography; const SEARCH_THROTTLE = 500; +const SEARCH_RATE_LIMIT_WAIT = 5000; + +async function performAutocomplete( + query: string, + waitingForRef: MutableRefObject, + setResults: (query: string, results: SearchResult | undefined) => void, + setRateLimitHit: Dispatch> +) { + debug("performing search for %s", query); + + // 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); + } 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); + } + } +} export function Search(): JSX.Element { const { t } = useTranslation(); const [value, setValue] = useState(""); + const [results, setResults] = useState(); + const [loading, setLoading] = useState(false); + const [rateLimitHit, setRateLimitHit] = useState(false); + + // The latest input that we're waiting for a network request for; this avoids + // out of order search results due to network latency + const waitingForRef = useRef(""); + + const debouncedAutocomplete = useMemo(() => debounce(performAutocomplete, SEARCH_THROTTLE), []); + const throttledAutocomplete = useMemo(() => throttle(performAutocomplete, SEARCH_THROTTLE), []); + + // LRU cache used to keep track of known search results. This avoids + // re-fetching search results when the user hits backspaces several times. + // The cache is cleared each time the search is focused to keep the results + // fresh. + const searchCache = 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); + + // 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); + } + + function onSearch(query: string) { + // Cowardly refuse to perform any search if the rate limit was hit + if (rateLimitHit) return; + + const cleanQuery = query.trim(); + if (!cleanQuery) { + setLoading(false); + return setResults(undefined); + } + + // Use the search cache if possible, to avoid unnecessary network requests + const cached = searchCache.get(cleanQuery); + if (cached) { + debug("using cached result for %s", query); + + // Ensure that an out of order request doesn't overwrite our cached result + waitingForRef.current = query; + + // Cancel any existing throttled request + throttledAutocomplete.cancel(); + debouncedAutocomplete.cancel(); + + setResults(cached); + setLoading(false); + return; + } + + setLoading(true); + + // Based on this article: + // https://www.peterbe.com/plog/how-to-throttle-and-debounce-an-autocomplete-input-in-react + // 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); + } + + function renderResults(): { value: string; label: ReactNode }[] { + // 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")} + }]; + } + + // Don't return anything if there's no query at all + const cleanQuery = value.trim(); + if (!cleanQuery) return []; + + // Loading spinner, only if we don't already have some results + if (loading && !results) { + return [{ value: "loading", label: ( +
+ +
+ )}]; + } + + // No results placeholder + if (!loading && !results) { + return [{ + value: "no_results", + label: {t("nav.search.noResults")} + }]; + } + + return []; + } return
true} + + onChange={value => { + setLoading(true); + setValue(value); + }} + onSearch={onSearch} + onFocus={() => { + debug("clearing search cache"); + searchCache.reset(); + }} + + options={renderResults()} > - +
; } diff --git a/src/pages/dashboard/BlockDifficultyCard.tsx b/src/pages/dashboard/BlockDifficultyCard.tsx index f17f671..6a0f763 100644 --- a/src/pages/dashboard/BlockDifficultyCard.tsx +++ b/src/pages/dashboard/BlockDifficultyCard.tsx @@ -10,7 +10,7 @@ import { Line } from "react-chartjs-2"; -import * as api from "../../krist/api/api"; +import * as api from "../../krist/api"; import { estimateHashRate } from "../../utils/currency"; import { KristConstants } from "../../krist/api/types"; import { trailingThrottleState } from "../../utils/promiseThrottle";