Newer
Older
CrypticOreWallet / src / layout / nav / Search.tsx
@Drew Lemmy Drew Lemmy on 30 Mar 2021 15 KB chore: more error handling
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, useMemo, useRef, useEffect, useCallback, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react";
import { AutoComplete, Input } from "antd";
import { RefSelectProps } from "antd/lib/select";

import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";

import { GlobalHotKeys } from "react-hotkeys";
import { ctrl } from "@utils";
import { useBreakpoint } from "@utils/hooks";

import { RateLimitError } from "@api";
import { SearchResult, search, searchExtended, SearchExtendedResult } from "@api/search";
import { throttle, debounce } from "lodash-es";
import LRU from "lru-cache";

import * as SearchResults from "./SearchResults";

import * as Sentry from "@sentry/react";
import Debug from "debug";
const debug = Debug("kristweb:search");

const SEARCH_THROTTLE = 500;
const SEARCH_RATE_LIMIT_WAIT = 5000;

async function performAutocomplete(
  query: string,
  fetchResults: boolean,
  fetchExtended: boolean,
  waitingForRef: MutableRefObject<string>,
  setResults: (query: string, results: SearchResult | undefined) => void,
  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.
  waitingForRef.current = query;

  try {
    await Promise.all([
      fetchResults ? search(query.toLowerCase()).then(r => setResults(query, r)) : undefined,
      fetchExtended ? searchExtended(query).then(r => setExtendedResults(query, r)) : undefined,
    ]);
  } catch (err) {
    // Most likely error is `rate_limit_hit`:
    if (err instanceof RateLimitError) {
      onRateLimitHit();
    } else {
      Sentry.withScope(scope => {
        scope.setTag("search-query", query);
        Sentry.captureException(err);
        console.error(err);
      });
    }
  }
}

export function Search(): JSX.Element {
  const { t } = useTranslation();
  const history = useHistory();

  // Used to change the placeholder depending on the screen width
  const bps = useBreakpoint();

  const [value, setValue] = useState("");
  const [results, setResults] = useState<SearchResult | undefined>();
  const [extendedResults, setExtendedResults] = useState<SearchExtendedResult | undefined>();
  const [loading, setLoading] = useState(false);
  const [rateLimitHit, setRateLimitHit] = useState(false);
  const [options, setOptions] = useState<{ value: string; label: ReactNode }[]>([]);

  // 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("");

  // Used to focus the search when the hotkey is received, or de-focus it when
  // a search result is selected
  const autocompleteRef = useRef<RefSelectProps | null>(null);

  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.
  const searchCache = useMemo(() => new LRU<string, SearchResult>({ max: 100, maxAge : 180000 }), []);
  const searchExtendedCache = useMemo(() => new LRU<string, SearchExtendedResult>({ max: 100, maxAge : 180000 }), []);

  // Create a function to set the results for a given result type
  const cachedSetResultsBase =
    <T extends SearchResult | SearchExtendedResult>(cache: LRU<string, T>, setResultsFn: Dispatch<SetStateAction<T | undefined>>) =>
      (query: string, results: T | undefined) => {
        debug("setting results for %s", query, results);

        // 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;
        }

        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("onSearch: %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;
    }

    // Use the search cache if possible, to avoid unnecessary network requests
    const cached = searchCache.get(cleanQuery);
    const cachedExtended = searchExtendedCache.get(cleanQuery);
    if (cached || cachedExtended) {
      debug("using cached result for %s (results: %b) (extended: %b)", query, !cached, !cachedExtended, cached, cachedExtended);

      // 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();

      if (cached) setResults(cached);
      if (cachedExtended) setExtendedResults(cachedExtended);

      setLoading(false);
    }

    // If we're missing one or both of the cached result sets, fetch them
    if (!cached || !cachedExtended) {
      debug("nothing cached for %s, (results: %b) (extended: %b), considering a fetch", query, !cached, !cachedExtended);

      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,
        !cached, !cachedExtended,
        waitingForRef,
        cachedSetResults, cachedSetExtendedResults,
        onRateLimitHit
      );
    }
  }

  /** Navigate to the selected search result. */
  function onSelect(query: string) {
    debug("onSelect %s", query);

    // Reset the search value when a result is selected. This is because,
    // otherwise, the internal value (e.g. `exactAddress`) would remain in
    // there, which would look pretty odd.
    // REVIEW: Would be nice to avoid having to do it this way entirely.
    setValue("");

    // If we're still loading the results, don't search just yet.
    // TODO: is it possible to defer this instead?
    if (loading || !results) return;

    const resultsMatches = results.matches;
    const { exactAddress, exactName, exactBlock, exactTransaction } = resultsMatches;

    debug("search selected value %s", query);

    // Whether or not we actually matched a value. This should pretty much
    // always be true.
    let matched = true;

    // Using the internal result type, navigate to the relevant page.
    // FIXME: this is kinda wack
    if (query === "exactAddress" && exactAddress) {
      history.push(`/network/addresses/${encodeURIComponent(exactAddress.address)}`);
    } else if (query === "exactName" && exactName) {
      history.push(`/network/names/${encodeURIComponent(exactName.name)}`);
    } else if (query === "exactBlock" && exactBlock) {
      history.push(`/network/blocks/${encodeURIComponent(exactBlock.height)}`);
    } else if (query === "exactTransaction" && exactTransaction) {
      history.push(`/network/transactions/${encodeURIComponent(exactTransaction.id)}`);
    } else if (extendedResults) {
      const { originalQuery } = extendedResults.query;
      const q = "?q=" + encodeURIComponent(originalQuery);
      debug("extended search query: %s (?q: %s)", originalQuery, q);

      if (query === "extendedTransactionsAddress") {
        history.push("/network/search/transactions/address" + q);
      } else if (query === "extendedTransactionsName") {
        history.push("/network/search/transactions/name" + q);
      } else if (query === "extendedTransactionsMetadata") {
        history.push("/network/search/transactions/metadata" + q);
      } else {
        matched = false;
        debug("warn: unknown search type %s", query);
      }
    } else {
      matched = false;
      debug("warn: unknown search type %s", query);
    }

    // De-focus the search textbox when an item is selected.
    if (matched && autocompleteRef.current)
      autocompleteRef.current.blur();
  }

  // When the 'enter' key is pressed while an autocomplete option isn't focused,
  // or the user clicks the 'search' button, the autocomplete has no way of
  // knowing which option to search with. So, we look at the first option in the
  // list and send that to onSelect.
  function onInputSearch() {
    // If we're still loading the results, don't search just yet.
    // TODO: is it possible to defer this instead?
    if (loading || !results) return;

    if (!options || !options.length) return;
    onSelect(options[0].value);
  }

  const staticOption = (value: string, label: ReactNode) => [{ value, label }];
  const renderOptions = useCallback(function(): { value: string; label: ReactNode }[] {
    const cleanQuery = value.trim();
    // debug("current state: %b %b %b %b", rateLimitHit, !cleanQuery, loading, results);

    // Show a warning instead of the results if the rate limit was hit
    if (rateLimitHit) return staticOption("rateLimitHit", <SearchResults.RateLimitHit />);
    // Don't return anything if there's no query at all
    if (!cleanQuery) return [];

    if (!results) {
      // Loading spinner, only if we don't already have some results
      if (loading) return staticOption("loading", <SearchResults.Loading />);
      else return staticOption("noResults", <SearchResults.NoResults />);
    }

    const resultsMatches = results.matches;

    // The list of results to return for the AutoComplete component
    const options = [];

    // The 'exact match' results; these are pretty immediate and return
    // definitive data
    const { exactAddress, exactName, exactBlock, exactTransaction } = resultsMatches;

    if (exactAddress) options.push({
      value: "exactAddress",
      label: <SearchResults.ExactAddressMatch address={exactAddress} />
    });
    if (exactName) options.push({
      value: "exactName",
      label: <SearchResults.ExactNameMatch name={exactName} />
    });
    if (exactBlock) options.push({
      value: "exactBlock",
      label: <SearchResults.ExactBlockMatch block={exactBlock} />
    });
    if (exactTransaction) options.push({
      value: "exactTransaction",
      label: <SearchResults.ExactTransactionMatch transaction={exactTransaction} />
    });

    // The 'extended' results; these are counts of transactions and may take a
    // bit longer to load. They're only shown if the query is longer than 3
    // characters.
    if (cleanQuery.length > 3) {
      // Whether or not to show the loading spinner on the extended items.
      // This is a pretty poor way to track if the extended results are still
      // loading some new value.
      const extendedLoading = loading && (!extendedResults || extendedResults.query.originalQuery !== cleanQuery);
      const extendedMatches = extendedResults?.matches?.transactions;

      // Do our own checks to preemptively know what kind of transaction results
      // will be shown. Note that metadata will always be searched.
      const addressInvolved = extendedMatches?.addressInvolved;
      const showAddress = (addressInvolved !== false && addressInvolved !== undefined)
        && exactAddress; // We definitely know the address exists

      const nameInvolved = extendedMatches?.nameInvolved;
      const showName = (nameInvolved !== false && nameInvolved !== undefined)
        && exactName; // We definitely know the name exists

      if (showAddress) options.push({
        value: "extendedTransactionsAddress",
        label: <SearchResults.ExtendedAddressMatch
          loading={extendedLoading}
          count={typeof addressInvolved === "number" ? addressInvolved : undefined}
          query={value}
        />
      });

      if (showName) options.push({
        value: "extendedTransactionsName",
        label: <SearchResults.ExtendedNameMatch
          loading={extendedLoading}
          count={typeof nameInvolved === "number" ? nameInvolved : undefined}
          query={extendedResults?.query?.strippedName || value}
        />
      });

      // Metadata is always searched
      options.push({
        value: "extendedTransactionsMetadata",
        label: <SearchResults.ExtendedMetadataMatch
          loading={extendedLoading}
          count={typeof extendedMatches?.metadata === "number" ? extendedMatches.metadata : undefined}
          query={value}
        />
      });
    }

    return options;
  }, [value, loading, rateLimitHit, results, extendedResults]);

  useEffect(() => {
    setOptions(renderOptions());
  }, [renderOptions]);

  return <div className="site-header-search-container">
    <GlobalHotKeys
      // REVIEW: I know that Mousetrap supports "mod+k" to maintain correct
      //         behaviour with Command/Ctrl between Windows/Linux and macOS,
      //         however I was unable to get it working on Windows in my limited
      //         testing. Thus, it needs to be reviewed if this works on macOS
      //         at all.
      keyMap={{ SEARCH: ["command+k", "ctrl+k"] }}
      handlers={{
        SEARCH: e => {
          e?.preventDefault();
          autocompleteRef.current?.focus();
        }
      }}
    />

    <AutoComplete
      // Used to focus the search when the hotkey is received, or de-focus it
      // when a search result is selected
      ref={autocompleteRef}

      // Required to make the dropdown show on an Input.Search:
      dropdownMatchSelectWidth={true}
      dropdownClassName="site-header-search-menu"
      className="site-header-search"
      value={value}

      // Always show all options; our search is responsible for providing them
      filterOption={() => true}

      onChange={value => {
        // debug("search onChange %s", value);
        setLoading(true);
        setValue(value);
      }}
      onSearch={onSearch}
      onSelect={onSelect}

      // NOTE: This was removed and the LRU expiry time was lowered; a definite
      //       decision on whether or not the cache should be cleared every time
      //       the search is opened hasn't been reached, but at the moment it
      //       seems to be better to just keep the cached entries around, as
      //       speed and lack of network spam is better than accuracy of the
      //       result hints. Besides, pressing enter will always take you to the
      //       up-to-date data anyway.
      /* onFocus={() => {
        debug("clearing search cache");
        searchCache.reset();
      }} */

      options={options}
    >
      <Input.Search
        placeholder={bps.md
          ? t("nav.search.placeholderShortcut", { shortcut: `${ctrl}+K` })
          : t("nav.search.placeholderShort")}
        onSearch={onInputSearch}

        role="searchbox"
        aria-label={t("nav.search.placeholder")}
      />
    </AutoComplete>
  </div>;
}