diff --git a/src/components/addresses/AddressPicker.tsx b/src/components/addresses/AddressPicker.tsx index 7741bc5..b886d3f 100644 --- a/src/components/addresses/AddressPicker.tsx +++ b/src/components/addresses/AddressPicker.tsx @@ -7,7 +7,11 @@ import { useTranslation, TFunction } from "react-i18next"; +import { useSelector } from "react-redux"; +import { RootState } from "@store"; + import { useWallets, Wallet, WalletMap } from "@wallets"; +import { isValidAddress } from "@utils/currency"; import { KristValue } from "@comp/krist/KristValue"; @@ -23,27 +27,59 @@ const [value, setValue] = useState(""); - const { wallets } = useWallets(); + // Note that the address picker's options are memoised against the wallets + // (and soon the address book too), but to save on time and expense, the + // 'exact address' match is prepended to these options dynamically. + const { wallets, addressList } = useWallets(); const options = useMemo(() => getOptions(t, wallets), [t, wallets]); + // Check if the input text is an exact address. If it is, create an extra item + // to prepend to the list. Note that the 'exact address' item is NOT shown if + // the picker wants wallets only, or if the exact address already appears as a + // wallet (or later, an address book entry). + const cleanValue = value.toLowerCase().trim(); + const addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); + const hasExactAddress = !walletsOnly + && isValidAddress(addressPrefix, cleanValue) + && !addressList.includes(cleanValue); + const exactAddressItem = hasExactAddress + ? { + ...getCategoryHeader(t("addressPicker.categoryExactAddress")), + options: [getAddressItem({ address: cleanValue })] + } + : undefined; + const classes = classNames("address-picker", className, { - "address-picker-wallets-only": walletsOnly + "address-picker-wallets-only": walletsOnly, + "address-picker-has-exact": hasExactAddress }); + // TODO: Wrap this in a Form.Item in advance? Every place I can think of off + // the top of my head will be using this in a form, so it might be good + // to provide some of the validation logic here. + // - Send Transaction Page (to/from) - Form + // - Receive Transaction Page (to) - Form + // - Name Purchase Page (owner) - Form + // - Name Transfer Page (to) - Form + // - Mining Page (to) - Possibly a form, can get away with making it one return { // Returning false if the option contains children will allow the select - // to return _all_ children; no idea why. + // to run filterOption for each child of that option group. if (option?.options || !inputValue) return false; + // TODO: Do we want to filter categories here too? const inp = inputValue.toUpperCase(); @@ -56,7 +92,10 @@ return matchedAddress || matchedLabel; }} - options={options} + // Prepend the exact address item if it exists + options={hasExactAddress && exactAddressItem + ? [exactAddressItem, ...options] + : options} onChange={setValue} value={value} @@ -66,21 +105,11 @@ />; } -function getCategoryHeader(category: string) { - return { - label: ( -
- {category} -
- ) - }; -} - -interface AddressItemProps { - address?: string; - wallet?: Wallet; -} - +// Ant design's autocomplete/select/rc-select components don't seem to return +// the proper types for these, so just provide our own types that are 'good +// enough'. I have a feeling the AutoComplete/Select components just accept +// basically anything for options, and passes the full objects down as props. +// The documentation on the topic is very limited. interface OptionValue { label: React.ReactNode; @@ -91,7 +120,29 @@ } interface OptionChildren { label: React.ReactNode; options: OptionValue[] } type Option = OptionValue | OptionChildren; + +function getCategoryHeader(category: string) { + return { + label: ( +
+ {category} +
+ ), + + // Will possibly be used for filtering. See OptionValue for a comment on + // the naming of this prop. + "data-picker-category": category + }; +} + +interface AddressItemProps { + address?: string; + wallet?: Wallet; +} + +/** Autocompletion option for the address picker. */ function getAddressItem({ address, wallet }: AddressItemProps): OptionValue { + // The address to use as a value, use the wallet if provided const plainAddress = wallet ? wallet.address : address; return { @@ -100,10 +151,10 @@ {/* Wallet label/address */}
{wallet && wallet.label - ? <> + ? <> {/* Show the label if possible: */} {wallet.label}  ({wallet.address}) - + // Otherwise just show the address: : {plainAddress}}
@@ -112,6 +163,7 @@ ), + // The wallet label is used for filtering the options "data-wallet-label": wallet?.label, value: plainAddress! }; @@ -122,6 +174,9 @@ uncategorised: OptionValue[]; categoryCount: number; } + +/** Groups the wallets by category for autocompletion and generates their select + * options. */ function getWalletOptions(wallets: WalletMap): WalletOptions { const categorised: Record = {}; const uncategorised: OptionValue[] = []; @@ -129,8 +184,11 @@ for (const id in wallets) { const wallet = wallets[id]; const { category } = wallet; + + // Generate the autocomplete option for this wallet const item = getAddressItem({ wallet }); + // Group it by category if possible if (category) { if (categorised[category]) categorised[category].push(item); else categorised[category] = [item]; @@ -146,16 +204,22 @@ }; } +/** Gets the base options to show for autocompletion, including the wallets, + * grouped by category if possible. Will include the address book soon too. */ function getOptions(t: TFunction, wallets: WalletMap): Option[] { + // Wallet options const { categorised, uncategorised, categoryCount } = getWalletOptions(wallets); + // Sort the wallet categories in a human-friendly manner const sortedCategories = Object.keys(categorised); sortedCategories.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true })); + // Generate the option groups for each category, along with the corresponding + // wallet entries. const categoryItems = sortedCategories.map(c => ({ ...getCategoryHeader(c), options: categorised[c] @@ -171,6 +235,8 @@ ? t("addressPicker.categoryOtherWallets") : t("addressPicker.categoryWallets")), options: uncategorised - } + }, + + // TODO: Address book ]; } diff --git a/src/utils/currency.ts b/src/utils/currency.ts index f1b903f..81c5099 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -3,6 +3,11 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { memoize, escapeRegExp, truncate, toString } from "lodash-es"; +// ----------------------------------------------------------------------------- +// NAMES +// ----------------------------------------------------------------------------- +// Cheap way to avoid RegExp DoS +const MAX_NAME_SUFFIX_LENGTH = 6; const _cleanNameSuffix = (nameSuffix: string | undefined | null): string => { // Ensure the name suffix is safe to put into a RegExp const stringSuffix = toString(nameSuffix); @@ -12,8 +17,6 @@ }; export const cleanNameSuffix = memoize(_cleanNameSuffix); -// Cheap way to avoid RegExp DoS -const MAX_NAME_SUFFIX_LENGTH = 6; const _getNameRegex = (nameSuffix: string | undefined | null, metadata?: boolean): RegExp => new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64}\\.${cleanNameSuffix(nameSuffix)})${metadata ? ";?" : "$"}`); export const getNameRegex = memoize(_getNameRegex); @@ -28,6 +31,58 @@ export const stripNameFromMetadata = (nameSuffix: string | undefined | null, metadata: string): string => metadata.replace(getNameRegex(nameSuffix, true), ""); +// ----------------------------------------------------------------------------- +// ADDRESSES +// ----------------------------------------------------------------------------- +const MAX_ADDRESS_PREFIX_LENGTH = 1; +const _cleanAddressPrefix = (addressPrefix: string | undefined | null): string => { + // This might be slightly cursed when the max prefix length is 1 character, + // but let's call it future-proofing. + const stringPrefix = toString(addressPrefix); + const shortPrefix = truncate(stringPrefix, { length: MAX_ADDRESS_PREFIX_LENGTH, omission: "" }); + const escaped = escapeRegExp(shortPrefix); + return escaped; +}; +export const cleanAddressPrefix = memoize(_cleanAddressPrefix); + +// Supports v1 addresses too +const _getAddressRegex = (addressPrefix: string | undefined | null): RegExp => + new RegExp(`^(?:${cleanAddressPrefix(addressPrefix)}[a-z0-9]{9}|[a-f0-9]{10})$`); +export const getAddressRegex = memoize(_getAddressRegex); + +// Only supports v2 addresses +const _getAddressRegexV2 = (addressPrefix: string | undefined | null): RegExp => + new RegExp(`^${cleanAddressPrefix(addressPrefix)}[a-z0-9]{9}$`); +export const getAddressRegexV2 = memoize(_getAddressRegexV2); + +/** + * Returns whether or not an address is a valid Krist address for the current + * sync node. + * + * @param addressPrefix - The single-character address prefix provided by the + * sync node. + * @param address - The address to check for validity. + * @param allowV1 - Whether or not the function should validate v1 addresses. + * Note that as of February 2021, the Krist server no longer accepts + * any kind of transaction to/from a v1 address, so features that are + * validating an address for purpose of a transaction (e.g. the address + * picker) should NOT set this to true. + */ +export function isValidAddress( + addressPrefix: string | undefined | null, + address: string, + allowV1?: boolean +): boolean { + return allowV1 + ? getAddressRegex(addressPrefix).test(address) + : getAddressRegexV2(addressPrefix).test(address); +} + + +// ----------------------------------------------------------------------------- +// MISC +// ----------------------------------------------------------------------------- + /** * Estimates the network mining hash-rate, returning it as a formatted string. *