diff --git a/public/locales/en.json b/public/locales/en.json index c1a7d2d..0cbe566 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -746,7 +746,12 @@ "categoryOtherWallets": "Other wallets", "categoryAddressBook": "Address book", "categoryExactAddress": "Exact address", - "categoryExactName": "Exact name" + "categoryExactName": "Exact name", + + "addressHint": "Balance: <1 />", + "addressHintWithNames": "Names: <1>{{names, number}}", + "nameHint": "Owner: <1 />", + "nameHintNotFound": "Name not found." }, "sendTransaction": { diff --git a/src/components/addresses/ContextualAddress.tsx b/src/components/addresses/ContextualAddress.tsx index 3e5570a..a520d17 100644 --- a/src/components/addresses/ContextualAddress.tsx +++ b/src/components/addresses/ContextualAddress.tsx @@ -4,15 +4,13 @@ import classNames from "classnames"; import { Tooltip, Typography } from "antd"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { KristAddress } from "@api/types"; import { Wallet, useWallets } from "@wallets"; import { parseCommonMeta, CommonMeta } from "@utils/commonmeta"; -import { stripNameSuffix } from "@utils/currency"; +import { useNameSuffix, stripNameSuffix } from "@utils/currency"; import { useBooleanSetting } from "@utils/settings"; import { KristNameLink } from "../names/KristNameLink"; @@ -84,7 +82,7 @@ }: Props): JSX.Element { const { t } = useTranslation(); const { walletAddressMap } = useWallets(); - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); const addressCopyButtons = useBooleanSetting("addressCopyButtons"); if (!origAddress) return ( diff --git a/src/components/addresses/picker/AddressHint.tsx b/src/components/addresses/picker/AddressHint.tsx new file mode 100644 index 0000000..d2d086a --- /dev/null +++ b/src/components/addresses/picker/AddressHint.tsx @@ -0,0 +1,33 @@ +// 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 { useTranslation, Trans } from "react-i18next"; + +import { KristAddressWithNames } from "@api/lookup"; +import { KristValue } from "@comp/krist/KristValue"; + +interface Props { + address?: KristAddressWithNames; + nameHint?: boolean; +} + +export function AddressHint({ address, nameHint }: Props): JSX.Element { + const { t } = useTranslation(); + + return + {nameHint + ? ( + // Show the name count if this picker is relevant to a name transfer + + Balance: {{ names: address?.names || 0 }} + + ) + : ( + // Otherwise, show the balance + + Balance: + + ) + } + ; +} diff --git a/src/components/addresses/picker/AddressPicker.less b/src/components/addresses/picker/AddressPicker.less index b3a598b..13ad66d 100644 --- a/src/components/addresses/picker/AddressPicker.less +++ b/src/components/addresses/picker/AddressPicker.less @@ -3,6 +3,14 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt @import (reference) "../../../App.less"; +.address-picker { + margin-bottom: @form-item-margin-bottom; + + .ant-form-item { + margin-bottom: 0; + } +} + .address-picker-dropdown { .address-picker-address-item { display: flex; diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx index aff7ad8..5bd5b39 100644 --- a/src/components/addresses/picker/AddressPicker.tsx +++ b/src/components/addresses/picker/AddressPicker.tsx @@ -8,11 +8,9 @@ import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; - import { useWallets } from "@wallets"; import { + useAddressPrefix, useNameSuffix, isValidAddress, getNameParts, getNameRegex, getAddressRegexV2 } from "@utils/currency"; @@ -20,6 +18,7 @@ import { getCategoryHeader } from "./Header"; import { getAddressItem } from "./Item"; import { getOptions } from "./options"; +import { usePickerHints } from "./PickerHints"; import "./AddressPicker.less"; @@ -31,6 +30,7 @@ walletsOnly?: boolean; noNames?: boolean; + nameHint?: boolean; className?: string; } @@ -43,6 +43,7 @@ walletsOnly, noNames, + nameHint, className, ...props @@ -61,8 +62,8 @@ // 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 addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); - const hasExactAddress = cleanValue + const addressPrefix = useAddressPrefix(); + const hasExactAddress = !!cleanValue && !walletsOnly && isValidAddress(addressPrefix, cleanValue) && !addressList.includes(cleanValue); @@ -75,10 +76,10 @@ // Check if the input text is an exact name. It may begin with a metaname, but // must end with the name suffix. - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); const nameParts = !walletsOnly && !noNames ? getNameParts(nameSuffix, cleanValue) : undefined; - const hasExactName = cleanValue + const hasExactName = !!cleanValue && !walletsOnly && !noNames && !!nameParts?.name; @@ -100,6 +101,9 @@ ] : options; + // Fetch an address or name hint if possible + const pickerHints = usePickerHints(nameHint, cleanValue, hasExactName); + const classes = classNames("address-picker", className, { "address-picker-wallets-only": walletsOnly, "address-picker-no-names": noNames, @@ -107,112 +111,109 @@ "address-picker-has-exact-name": hasExactName, }); - // 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 + { - const addressRegexp = getAddressRegexV2(addressPrefix); + // Address/name regexp + { + type: "method", + async validator(_, value): Promise { + const addressRegexp = getAddressRegexV2(addressPrefix); - if (walletsOnly || noNames) { - // Only validate with addresses - if (!addressRegexp.test(value)) { - if (walletsOnly) throw t("addressPicker.errorInvalidWalletsOnly"); - else throw t("addressPicker.errorInvalidAddressOnly"); + if (walletsOnly || noNames) { + // Only validate with addresses + if (!addressRegexp.test(value)) { + if (walletsOnly) + throw t("addressPicker.errorInvalidWalletsOnly"); + else throw t("addressPicker.errorInvalidAddressOnly"); + } + } else { + // Validate addresses and names + const nameRegexp = getNameRegex(nameSuffix); + if (!addressRegexp.test(value) && !nameRegexp.test(value)) + throw t("addressPicker.errorInvalidRecipient"); } - } else { - // Validate addresses and names - const nameRegexp = getNameRegex(nameSuffix); - if (!addressRegexp.test(value) && !nameRegexp.test(value)) - throw t("addressPicker.errorInvalidRecipient"); } - } - }, + }, - // If this is walletsOnly, add an additional rule to enforce that the - // given address is a wallet we actually own - ...(walletsOnly ? [{ - type: "enum", - enum: addressList, - message: t("addressPicker.errorInvalidWalletsOnly") - } as Rule] : []), + // If this is walletsOnly, add an additional rule to enforce that the + // given address is a wallet we actually own + ...(walletsOnly ? [{ + type: "enum", + enum: addressList, + message: t("addressPicker.errorInvalidWalletsOnly") + } as Rule] : []), - // If we have another address picker's value, assert that they are not - // equal (e.g. to/from in a transaction can't be equal) - ...(otherPickerValue ? [{ - async validator(_, value): Promise { - if (value === otherPickerValue) - throw t("addressPicker.errorEqual"); - } - } as Rule] : []) - ]} + // If we have another address picker's value, assert that they are not + // equal (e.g. to/from in a transaction can't be equal) + ...(otherPickerValue ? [{ + async validator(_, value): Promise { + if (value === otherPickerValue) + throw t("addressPicker.errorEqual"); + } + } as Rule] : []) + ]} - {...props} - > - + { - // Returning false if the option contains children will allow the select - // to run filterOption for each child of that option group. - if (option?.options) return false; - // TODO: Do we want to filter categories here too? + // Filter the options based on the input text + filterOption={(inputValue, option) => { + // Returning false if the option contains children will allow the + // select to run filterOption for each child of that option group. + if (option?.options) return false; + // TODO: Do we want to filter categories here too? - const address = option!.value?.toUpperCase(); - const walletLabel = option!["data-wallet-label"]?.toUpperCase(); + const address = option!.value?.toUpperCase(); + const walletLabel = option!["data-wallet-label"]?.toUpperCase(); - // If we have another address picker's value, hide that option from the - // list (it will always be a wallet) - // FIXME: filterOption doesn't get called at all when inputValue is - // blank, which means this option will still appear until the - // user actually starts typing. - if (otherPickerValue?.toUpperCase() === address) - return false; + // If we have another address picker's value, hide that option from + // the list (it will always be a wallet) + // FIXME: filterOption doesn't get called at all when inputValue is + // blank, which means this option will still appear until the + // user actually starts typing. + if (otherPickerValue?.toUpperCase() === address) + return false; - // Now that we've filtered out the other picker's value, we can allow - // every other option if there's no input - if (!inputValue) return true; + // Now that we've filtered out the other picker's value, we can allow + // every other option if there's no input + if (!inputValue) return true; - const inp = inputValue.toUpperCase(); + const inp = inputValue.toUpperCase(); - const matchedAddress = address.indexOf(inp) !== -1; - const matchedLabel = walletLabel?.indexOf(inp) !== -1; + const matchedAddress = address.indexOf(inp) !== -1; + const matchedLabel = walletLabel?.indexOf(inp) !== -1; - return matchedAddress || matchedLabel; - }} + return matchedAddress || matchedLabel; + }} - options={fullOptions} - /> - ; + options={fullOptions} + /> + + + {/* Show the address/name hints if they are present */} + {pickerHints} + ; } diff --git a/src/components/addresses/picker/NameHint.tsx b/src/components/addresses/picker/NameHint.tsx new file mode 100644 index 0000000..6aa521b --- /dev/null +++ b/src/components/addresses/picker/NameHint.tsx @@ -0,0 +1,29 @@ +// 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 { Typography } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; + +import { KristName } from "@api/types"; +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; + +const { Text } = Typography; + +interface Props { + name?: KristName; +} + +export function NameHint({ name }: Props): JSX.Element { + const { t } = useTranslation(); + + return + {name + ? ( + + Owner: + + ) + : {t("addressPicker.nameHintNotFound")}} + ; +} diff --git a/src/components/addresses/picker/PickerHints.tsx b/src/components/addresses/picker/PickerHints.tsx new file mode 100644 index 0000000..439f411 --- /dev/null +++ b/src/components/addresses/picker/PickerHints.tsx @@ -0,0 +1,98 @@ +// 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 { useState, useEffect, useMemo } from "react"; + +import { + isValidAddress, stripNameSuffix, + useAddressPrefix, useNameSuffix +} from "@utils/currency"; + +import * as api from "@api"; +import { KristAddressWithNames, lookupAddress } from "@api/lookup"; +import { KristName } from "@api/types"; + +import { AddressHint } from "./AddressHint"; +import { NameHint } from "./NameHint"; + +import { debounce } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("kristweb:address-picker-hints"); + +const HINT_LOOKUP_DEBOUNCE = 250; + +export function usePickerHints( + nameHint?: boolean, + value?: string, + hasExactName?: boolean +): JSX.Element | null { + const addressPrefix = useAddressPrefix(); + const nameSuffix = useNameSuffix(); + + // Handle showing an address or name hint if the value is valid + const [foundAddress, setFoundAddress] = useState(); + const [foundName, setFoundName] = useState(); + + const lookupHint = useMemo(() => debounce(( + nameSuffix: string, + value: string, + hasAddress?: boolean, + hasName?: boolean, + nameHint?: boolean + ) => { + debug("looking up hint for %s (address: %b) (name: %b)", + value, hasAddress, hasName); + + if (hasAddress) { + // Lookup an address + setFoundName(undefined); + lookupAddress(value, nameHint) + .then(setFoundAddress) + .catch(() => setFoundAddress(false)); + } else if (hasName) { + // Lookup a name + setFoundAddress(undefined); + + const rawName = stripNameSuffix(nameSuffix, value); + + api.get<{ name: KristName }>("names/" + encodeURIComponent(rawName)) + .then(res => setFoundName(res.name)) + .catch(() => setFoundName(false)); + } + }, HINT_LOOKUP_DEBOUNCE), []); + + // Look up the address/name if it is valid (debounced to 250ms) + useEffect(() => { + if (!value) { + setFoundAddress(undefined); + setFoundName(undefined); + return; + } + + // hasExactAddress fails for walletsOnly, so use this variant instead + const hasValidAddress = !!value + && isValidAddress(addressPrefix, value); + + if (!hasValidAddress && !hasExactName) { + setFoundAddress(undefined); + setFoundName(undefined); + return; + } + + // Perform the lookup (debounced) + lookupHint(nameSuffix, value, hasValidAddress, hasExactName, nameHint); + }, [lookupHint, nameSuffix, value, addressPrefix, hasExactName, nameHint]); + + // Return an address hint if possible + if (foundAddress !== undefined) return ( + + ); + + // Return a name hint if possible + if (foundName !== undefined) return ( + + ); + + return null; +} diff --git a/src/components/names/KristNameLink.tsx b/src/components/names/KristNameLink.tsx index df05013..a5d6130 100644 --- a/src/components/names/KristNameLink.tsx +++ b/src/components/names/KristNameLink.tsx @@ -4,11 +4,9 @@ import classNames from "classnames"; import { Typography } from "antd"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; - import { Link } from "react-router-dom"; +import { useNameSuffix } from "@utils/currency"; import { useBooleanSetting } from "@utils/settings"; const { Text } = Typography; @@ -22,7 +20,7 @@ type Props = React.HTMLProps & OwnProps; export function KristNameLink({ name, text, noLink, neverCopyable, ...props }: Props): JSX.Element | null { - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); const nameCopyButtons = useBooleanSetting("nameCopyButtons"); const copyNameSuffixes = useBooleanSetting("copyNameSuffixes"); diff --git a/src/components/names/NameARecordLink.tsx b/src/components/names/NameARecordLink.tsx index d7e605d..4f194cb 100644 --- a/src/components/names/NameARecordLink.tsx +++ b/src/components/names/NameARecordLink.tsx @@ -3,9 +3,7 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import classNames from "classnames"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; -import { stripNameSuffix } from "@utils/currency"; +import { useNameSuffix, stripNameSuffix } from "@utils/currency"; import { KristNameLink } from "./KristNameLink"; @@ -23,7 +21,7 @@ } export function NameARecordLink({ a, className }: Props): JSX.Element | null { - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); if (!a) return null; diff --git a/src/components/transactions/TransactionConciseMetadata.tsx b/src/components/transactions/TransactionConciseMetadata.tsx index 6e4b0db..497865c 100644 --- a/src/components/transactions/TransactionConciseMetadata.tsx +++ b/src/components/transactions/TransactionConciseMetadata.tsx @@ -3,11 +3,8 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import classNames from "classnames"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; - import { KristTransaction } from "@api/types"; -import { stripNameFromMetadata } from "@utils/currency"; +import { useNameSuffix, stripNameFromMetadata } from "@utils/currency"; import "./TransactionConciseMetadata.less"; @@ -23,7 +20,7 @@ * to a specified amount of characters. */ export function TransactionConciseMetadata({ transaction, metadata, limit = 30, className }: Props): JSX.Element | null { - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); // Don't render anything if there's no metadata (after the hooks) const meta = metadata || transaction?.metadata; diff --git a/src/global/ws/SyncMOTD.tsx b/src/global/ws/SyncMOTD.tsx index 8331231..0564008 100644 --- a/src/global/ws/SyncMOTD.tsx +++ b/src/global/ws/SyncMOTD.tsx @@ -13,6 +13,7 @@ import { KristMOTD, KristMOTDBase } from "@api/types"; import { recalculateWallets, useWallets } from "@wallets"; +import { useAddressPrefix } from "@utils/currency"; import Debug from "debug"; const debug = Debug("kristweb:sync-motd"); @@ -45,7 +46,7 @@ const connectionState = useSelector((s: RootState) => s.websocket.connectionState); // All these are used to determine if we need to recalculate the addresses - const addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); + const addressPrefix = useAddressPrefix(); const masterPassword = useSelector((s: RootState) => s.masterPassword.masterPassword); const { wallets } = useWallets(); diff --git a/src/pages/names/NamePage.tsx b/src/pages/names/NamePage.tsx index 3e251d8..9647bfa 100644 --- a/src/pages/names/NamePage.tsx +++ b/src/pages/names/NamePage.tsx @@ -8,9 +8,6 @@ import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; - import { PageLayout } from "@layout/PageLayout"; import { APIErrorResult } from "@comp/results/APIErrorResult"; @@ -22,7 +19,9 @@ import * as api from "@api"; import { KristName } from "@api/types"; import { LookupTransactionType as LookupTXType } from "@api/lookup"; + import { useWallets } from "@wallets"; +import { useNameSuffix } from "@utils/currency"; import { useBooleanSetting } from "@utils/settings"; import { NameButtonRow } from "./NameButtonRow"; @@ -149,7 +148,7 @@ export function NamePage(): JSX.Element { // Used to refresh the name data on syncNode change const syncNode = api.useSyncNode(); - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); const { name } = useParams(); const [kristName, setKristName] = useState(); diff --git a/src/pages/transactions/TransactionMetadataCard.tsx b/src/pages/transactions/TransactionMetadataCard.tsx index 3e70ecd..bbb7f5a 100644 --- a/src/pages/transactions/TransactionMetadataCard.tsx +++ b/src/pages/transactions/TransactionMetadataCard.tsx @@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import { RootState } from "@store"; - import { parseCommonMeta } from "@utils/commonmeta"; +import { useNameSuffix } from "@utils/currency"; import { HelpIcon } from "@comp/HelpIcon"; import { useBooleanSetting } from "@utils/settings"; @@ -93,7 +91,7 @@ export function TransactionMetadataCard({ metadata }: { metadata: string }): JSX.Element { const { t } = useTranslation(); - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + const nameSuffix = useNameSuffix(); // Default to the 'Raw' tab instead of 'CommonMeta' const defaultRaw = useBooleanSetting("transactionDefaultRaw"); diff --git a/src/pages/transactions/send/SendTransactionForm.tsx b/src/pages/transactions/send/SendTransactionForm.tsx index 82a921e..f42928e 100644 --- a/src/pages/transactions/send/SendTransactionForm.tsx +++ b/src/pages/transactions/send/SendTransactionForm.tsx @@ -39,9 +39,9 @@ const [from, setFrom] = useState(initialFrom); const [to, setTo] = useState(""); - function onValuesChange(changed: Partial) { - if (changed.from !== undefined) setFrom(changed.from); - if (changed.to !== undefined) setTo(changed.to); + function onValuesChange(_: unknown, values: Partial) { + setFrom(values.from || ""); + setTo(values.to || ""); } return
s.masterPassword.masterPassword); // Required to check for existing wallets const { wallets } = useWallets(); - const addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); + const addressPrefix = useAddressPrefix(); const { t } = useTranslation(); const bps = useBreakpoint(); diff --git a/src/utils/currency.ts b/src/utils/currency.ts index e7259d3..8e3ca78 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -1,6 +1,9 @@ // 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 { useSelector } from "react-redux"; +import { RootState } from "@store"; + import { memoize, escapeRegExp, truncate, toString } from "lodash-es"; // ----------------------------------------------------------------------------- @@ -134,3 +137,11 @@ const i = Math.min(Math.floor(Math.log(rate) / Math.log(1000)), sizes.length); return parseFloat((rate / Math.pow(1000, i)).toFixed(2)) + " " + sizes[i] + "/s"; } + +/** Hook to get the address prefix. */ +export const useAddressPrefix = (): string => + useSelector((s: RootState) => s.node.currency.address_prefix); + +/** Hook to get the name suffix. */ +export const useNameSuffix = (): string => + useSelector((s: RootState) => s.node.currency.name_suffix);