diff --git a/public/locales/en.json b/public/locales/en.json index 4ac36fb..f3230c1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -739,6 +739,7 @@ "categoryWallets": "Wallets", "categoryOtherWallets": "Other wallets", "categoryAddressBook": "Address book", - "categoryExactAddress": "Exact address" + "categoryExactAddress": "Exact address", + "categoryExactName": "Exact name" } } diff --git a/src/components/addresses/AddressPicker.less b/src/components/addresses/AddressPicker.less deleted file mode 100644 index 0d5159a..0000000 --- a/src/components/addresses/AddressPicker.less +++ /dev/null @@ -1,33 +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 (reference) "../../App.less"; - -.address-picker-dropdown { - .address-picker-address-item { - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; - - .krist-value { - flex: 0; - margin-left: auto; - padding-left: @padding-sm; - } - - .address-picker-item-content { - min-width: 0; - flex: 1; - } - - .address-picker-wallet-label { - white-space: normal; - word-break: break-word; - } - - .address-picker-wallet-label + .address-picker-wallet-address { - color: @text-color-secondary; - } - } -} diff --git a/src/components/addresses/AddressPicker.tsx b/src/components/addresses/AddressPicker.tsx deleted file mode 100644 index 6ac7087..0000000 --- a/src/components/addresses/AddressPicker.tsx +++ /dev/null @@ -1,245 +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, { useState, useMemo } from "react"; -import classNames from "classnames"; -import { AutoComplete } from "antd"; - -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"; - -import "./AddressPicker.less"; - -interface Props { - walletsOnly?: boolean; - className?: string; -} - -export function AddressPicker({ walletsOnly, className }: Props): JSX.Element { - const { t } = useTranslation(); - - const [value, setValue] = useState(""); - - // 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 = cleanValue - && !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-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 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(); - - const address = option!.value; - const walletLabel = option!["data-wallet-label"]; - - const matchedAddress = address.toUpperCase().indexOf(inp) !== -1; - const matchedLabel = walletLabel && walletLabel.toUpperCase().indexOf(inp) !== -1; - - return matchedAddress || matchedLabel; - }} - - // Prepend the exact address item if it exists - options={hasExactAddress && exactAddressItem - ? [exactAddressItem, ...options] - : options} - - onChange={setValue} - value={value} - - // TODO: remove this - style={{ minWidth: 300 }} - />; -} - -// 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; - - // For some reason, all these props get passed all the way to the DOM element! - // Make this a 'valid' DOM prop - "data-wallet-label"?: string; - value: string; -} -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 { - label: ( -
- {/* Wallet label/address */} -
- {wallet && wallet.label - ? <> {/* Show the label if possible: */} - {wallet.label}  - ({wallet.address}) - // Otherwise just show the address: - : {plainAddress}} -
- - {/* Wallet balance, if available */} - {wallet && } -
- ), - - // The wallet label is used for filtering the options - "data-wallet-label": wallet?.label, - value: plainAddress! - }; -} - -interface WalletOptions { - categorised: Record; - 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[] = []; - - 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]; - } else { - uncategorised.push(item); - } - } - - // TODO: sort the addresses too? - - return { - categorised, - uncategorised, - categoryCount: Object.keys(categorised).length - }; -} - -/** 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] - })); - - return [ - // Categorised wallets - ...categoryItems, - - // Uncategorised wallets - { - ...getCategoryHeader(categoryCount > 0 - ? t("addressPicker.categoryOtherWallets") - : t("addressPicker.categoryWallets")), - options: uncategorised - }, - - // TODO: Address book - ]; -} diff --git a/src/components/addresses/picker/AddressPicker.less b/src/components/addresses/picker/AddressPicker.less new file mode 100644 index 0000000..b3a598b --- /dev/null +++ b/src/components/addresses/picker/AddressPicker.less @@ -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 (reference) "../../../App.less"; + +.address-picker-dropdown { + .address-picker-address-item { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; + + .krist-value { + flex: 0; + margin-left: auto; + padding-left: @padding-sm; + } + + .address-picker-item-content { + min-width: 0; + flex: 1; + } + + .address-picker-wallet-label { + white-space: normal; + word-break: break-word; + } + + .address-picker-wallet-label + .address-picker-wallet-address { + color: @text-color-secondary; + } + } +} diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx new file mode 100644 index 0000000..5cd7cd9 --- /dev/null +++ b/src/components/addresses/picker/AddressPicker.tsx @@ -0,0 +1,131 @@ +// 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, useMemo } from "react"; +import classNames from "classnames"; +import { AutoComplete } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; + +import { useWallets } from "@wallets"; +import { isValidAddress, getNameParts } from "@utils/currency"; + +import { getCategoryHeader } from "./Header"; +import { getAddressItem } from "./Item"; +import { getOptions } from "./options"; + +import "./AddressPicker.less"; + +interface Props { + walletsOnly?: boolean; + className?: string; +} + +export function AddressPicker({ walletsOnly, className }: Props): JSX.Element { + const { t } = useTranslation(); + + const [value, setValue] = useState(""); + const cleanValue = value?.toLowerCase().trim(); + + // 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 addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); + const hasExactAddress = cleanValue + && !walletsOnly + && isValidAddress(addressPrefix, cleanValue) + && !addressList.includes(cleanValue); + const exactAddressItem = hasExactAddress + ? { + ...getCategoryHeader(t("addressPicker.categoryExactAddress")), + options: [getAddressItem({ address: cleanValue })] + } + : undefined; + + // 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 nameParts = !walletsOnly ? getNameParts(nameSuffix, cleanValue) : undefined; + const hasExactName = cleanValue && !walletsOnly && !!nameParts?.name; + const exactNameItem = hasExactName + ? { + ...getCategoryHeader(t("addressPicker.categoryExactName")), + options: [getAddressItem({ name: nameParts })] + } + : undefined; + + // Shallow copy the options if we need to prepend anything, otherwise use the + // original memoised array. Prepend the exact address or exact name if they + // are available. + const fullOptions = hasExactAddress || hasExactName + ? [ + ...(exactAddressItem ? [exactAddressItem] : []), + ...(exactNameItem ? [exactNameItem] : []), + ...options + ] + : options; + + const classes = classNames("address-picker", className, { + "address-picker-wallets-only": walletsOnly, + "address-picker-has-exact-address": hasExactAddress, + "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 { + // Returning false if the option contains children will allow the select + // 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(); + + const address = option!.value; + const walletLabel = option!["data-wallet-label"]; + + const matchedAddress = address.toUpperCase().indexOf(inp) !== -1; + const matchedLabel = walletLabel && walletLabel.toUpperCase().indexOf(inp) !== -1; + + return matchedAddress || matchedLabel; + }} + + options={fullOptions} + + onChange={setValue} + value={value} + + // TODO: remove this + style={{ minWidth: 300 }} + />; +} + diff --git a/src/components/addresses/picker/Header.tsx b/src/components/addresses/picker/Header.tsx new file mode 100644 index 0000000..6e2102e --- /dev/null +++ b/src/components/addresses/picker/Header.tsx @@ -0,0 +1,18 @@ +// 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"; + +export 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 + }; +} diff --git a/src/components/addresses/picker/Item.tsx b/src/components/addresses/picker/Item.tsx new file mode 100644 index 0000000..a60b085 --- /dev/null +++ b/src/components/addresses/picker/Item.tsx @@ -0,0 +1,72 @@ +// 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 { Wallet } from "@wallets"; +import { NameParts } from "@utils/currency"; + +import { KristValue } from "@comp/krist/KristValue"; + +import { OptionValue } from "./options"; + +interface AddressItemProps { + address?: string; + name?: NameParts; + wallet?: Wallet; +} + +function getPlainAddress({ address, name, wallet }: AddressItemProps): string { + if (wallet) return wallet.address; + if (name?.recipient) return name.recipient; + else return address || ""; +} + +function PickerContent({ + name, + wallet, + plainAddress +}: AddressItemProps & { plainAddress: string }): JSX.Element { + if (wallet && wallet.label) { + // Show the wallet label if possible + return <> + {wallet.label}  + ({wallet.address}) + ; + } else if (name?.recipient) { + // Show a formatted name if possible + const { metaname, nameWithSuffix } = name; + return <> + {metaname && {metaname}@} + {nameWithSuffix} + ; + } else { + // Just show a plain address + return {plainAddress}; + } +} + +/** Autocompletion option for the address picker. */ +export function getAddressItem(props: AddressItemProps): OptionValue { + // The address to use as a value + const plainAddress = getPlainAddress(props); + const { wallet } = props; + + return { + label: ( +
+ {/* Address, wallet label, or name */} +
+ +
+ + {/* Wallet balance, if available */} + {wallet && } +
+ ), + + // The wallet label is used for filtering the options + "data-wallet-label": wallet?.label, + value: plainAddress + }; +} diff --git a/src/components/addresses/picker/options.ts b/src/components/addresses/picker/options.ts new file mode 100644 index 0000000..3732dfd --- /dev/null +++ b/src/components/addresses/picker/options.ts @@ -0,0 +1,108 @@ +// 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 { TFunction } from "react-i18next"; + +import { WalletMap } from "@wallets"; + +import { getCategoryHeader } from "./Header"; +import { getAddressItem } from "./Item"; + +// 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. +export interface OptionValue { + label: React.ReactNode; + + // For some reason, all these props get passed all the way to the DOM element! + // Make this a 'valid' DOM prop + "data-wallet-label"?: string; + value: string; +} +export interface OptionChildren { + label: React.ReactNode; + options: OptionValue[]; +} +export type Option = OptionValue | OptionChildren; + +// ----------------------------------------------------------------------------- +// WALLET OPTIONS +// ----------------------------------------------------------------------------- +interface WalletOptions { + categorised: Record; + 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[] = []; + + 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]; + } else { + uncategorised.push(item); + } + } + + // TODO: sort the addresses too? + + return { + categorised, + uncategorised, + categoryCount: Object.keys(categorised).length + }; +} + +// ----------------------------------------------------------------------------- +// FULL OPTIONS +// ----------------------------------------------------------------------------- +/** Gets the base options to show for autocompletion, including the wallets, + * grouped by category if possible. Will include the address book soon too. */ +export 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] + })); + + return [ + // Categorised wallets + ...categoryItems, + + // Uncategorised wallets + { + ...getCategoryHeader(categoryCount > 0 + ? t("addressPicker.categoryOtherWallets") + : t("addressPicker.categoryWallets")), + options: uncategorised + }, + + // TODO: Address book + ]; +} diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx index c845875..046a21d 100644 --- a/src/pages/dev/DevPage.tsx +++ b/src/pages/dev/DevPage.tsx @@ -7,7 +7,7 @@ import { ImportBackupModal } from "../backup/ImportBackupModal"; import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; -import { AddressPicker } from "@comp/addresses/AddressPicker"; +import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; import { useWallets, deleteWallet } from "@wallets"; diff --git a/src/utils/commonmeta.ts b/src/utils/commonmeta.ts index 0f3949a..cb8c7df 100644 --- a/src/utils/commonmeta.ts +++ b/src/utils/commonmeta.ts @@ -1,7 +1,7 @@ // 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 { getNameRegex } from "./currency"; +import { getNameParts } from "./currency"; export interface CommonMeta { metaname?: string; @@ -25,19 +25,21 @@ const metaParts = metadata.split(";"); if (metaParts.length <= 0) return null; - const nameMatches = getNameRegex(nameSuffix).exec(metaParts[0]); - if (nameMatches) { - if (nameMatches[1]) out.metaname = nameMatches[1]; - if (nameMatches[2]) out.name = nameMatches[2]; + const nameParts = getNameParts(nameSuffix, metaParts[0]); + if (nameParts) { + out.metaname = nameParts.metaname; + out.name = nameParts.nameWithSuffix; - out.recipient = nameMatches[1] ? nameMatches[1] + "@" + nameMatches[2] : nameMatches[2]; + out.recipient = nameParts.metaname + ? nameParts.metaname + "@" + nameParts.nameWithSuffix + : nameParts.nameWithSuffix; } for (let i = 0; i < metaParts.length; i++) { const metaPart = metaParts[i]; const kv = metaPart.split("=", 2); - if (i === 0 && nameMatches) continue; + if (i === 0 && nameParts) continue; if (kv.length === 1) { custom[i.toString()] = kv[0]; @@ -48,12 +50,14 @@ const rawReturn = out.return = custom.return; if (rawReturn) { - const returnMatches = getNameRegex(nameSuffix).exec(rawReturn); - if (returnMatches) { - if (returnMatches[1]) out.returnMetaname = returnMatches[1]; - if (returnMatches[2]) out.returnName = returnMatches[2]; + const returnParts = getNameParts(nameSuffix, rawReturn); + if (returnParts) { + out.returnMetaname = returnParts.metaname; + out.returnName = returnParts.nameWithSuffix; - out.returnRecipient = returnMatches[1] ? returnMatches[1] + "@" + returnMatches[2] : returnMatches[2]; + out.returnRecipient = returnParts.metaname + ? returnParts.metaname + "@" + returnParts.nameWithSuffix + : returnParts.nameWithSuffix; } } diff --git a/src/utils/currency.ts b/src/utils/currency.ts index 81c5099..e7259d3 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -18,9 +18,41 @@ export const cleanNameSuffix = memoize(_cleanNameSuffix); const _getNameRegex = (nameSuffix: string | undefined | null, metadata?: boolean): RegExp => - new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64}\\.${cleanNameSuffix(nameSuffix)})${metadata ? ";?" : "$"}`); + new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64})(\\.${cleanNameSuffix(nameSuffix)})${metadata ? ";?" : "$"}`); export const getNameRegex = memoize(_getNameRegex); +export interface NameParts { + metaname?: string; + name?: string; + nameSuffix?: string; + nameWithSuffix?: string; + recipient?: string; +} +export function getNameParts( + nameSuffix: string | undefined | null, + name: string | undefined +): NameParts | undefined { + if (!nameSuffix || !name) return; + + const nameMatches = getNameRegex(nameSuffix).exec(name); + if (!nameMatches) return undefined; + + const mMetaname = nameMatches[1] || undefined; + const mName = nameMatches[2] || undefined; + const nameWithSuffix = mName ? mName + "." + nameSuffix : undefined; + const recipient = mMetaname + ? mMetaname + "@" + nameWithSuffix + : nameWithSuffix; + + return { + metaname: mMetaname, + name: mName, + nameSuffix, + nameWithSuffix, + recipient + }; +} + const _stripNameSuffixRegExp = (nameSuffix: string | undefined | null): RegExp => new RegExp(`\\.${cleanNameSuffix(nameSuffix)}$`); export const stripNameSuffixRegExp = memoize(_stripNameSuffixRegExp);