diff --git a/.vscode/settings.json b/.vscode/settings.json index d0e4f7c..a346ec4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,6 +25,7 @@ "arraybuffer", "authorised", "behaviour", + "categorised", "chartjs", "clientside", "commonmeta", @@ -57,6 +58,7 @@ "totalout", "tsdoc", "typeahead", + "uncategorised", "unmount", "unregistering", "unsyncable" diff --git a/public/locales/en.json b/public/locales/en.json index 5b55926..6e8c026 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -722,5 +722,21 @@ "walletLimitMessage": "You have more wallets stored than KristWeb supports. This was either caused by a bug, or you bypassed it intentionally. Expect issues with syncing.", - "optionalFieldUnset": "(unset)" + "optionalFieldUnset": "(unset)", + + "addressPicker": { + "placeholder": "Type an address, or choose a wallet", + "placeholderWalletsOnly": "Choose a wallet", + + "hintCurrentBalance": "Current balance: <1 />", + + "errorInvalidAddress": "Invalid address or name.", + "errorInvalidRecipient": "Invalid recipient. Must be an address or name.", + "errorInvalidWalletsOnly": "Invalid wallet address.", + + "categoryWallets": "Wallets", + "categoryOtherWallets": "Other wallets", + "categoryAddressBook": "Address book", + "categoryExactAddress": "Exact address" + } } diff --git a/src/components/addresses/AddressPicker.less b/src/components/addresses/AddressPicker.less new file mode 100644 index 0000000..0d5159a --- /dev/null +++ b/src/components/addresses/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/AddressPicker.tsx b/src/components/addresses/AddressPicker.tsx new file mode 100644 index 0000000..7741bc5 --- /dev/null +++ b/src/components/addresses/AddressPicker.tsx @@ -0,0 +1,176 @@ +// 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 { useWallets, Wallet, WalletMap } from "@wallets"; + +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(""); + + const { wallets } = useWallets(); + const options = useMemo(() => getOptions(t, wallets), [t, wallets]); + + const classes = classNames("address-picker", className, { + "address-picker-wallets-only": walletsOnly + }); + + return { + // Returning false if the option contains children will allow the select + // to return _all_ children; no idea why. + if (option?.options || !inputValue) return false; + + 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={options} + + onChange={setValue} + value={value} + + // TODO: remove this + style={{ minWidth: 300 }} + />; +} + +function getCategoryHeader(category: string) { + return { + label: ( +
+ {category} +
+ ) + }; +} + +interface AddressItemProps { + address?: string; + wallet?: Wallet; +} + +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 getAddressItem({ address, wallet }: AddressItemProps): OptionValue { + const plainAddress = wallet ? wallet.address : address; + + return { + label: ( +
+ {/* Wallet label/address */} +
+ {wallet && wallet.label + ? <> + {wallet.label}  + ({wallet.address}) + + : {plainAddress}} +
+ + {/* Wallet balance, if available */} + {wallet && } +
+ ), + + "data-wallet-label": wallet?.label, + value: plainAddress! + }; +} + +interface WalletOptions { + categorised: Record; + uncategorised: OptionValue[]; + categoryCount: number; +} +function getWalletOptions(wallets: WalletMap): WalletOptions { + const categorised: Record = {}; + const uncategorised: OptionValue[] = []; + + for (const id in wallets) { + const wallet = wallets[id]; + const { category } = wallet; + const item = getAddressItem({ wallet }); + + if (category) { + if (categorised[category]) categorised[category].push(item); + else categorised[category] = [item]; + } else { + uncategorised.push(item); + } + } + + return { + categorised, + uncategorised, + categoryCount: Object.keys(categorised).length + }; +} + +function getOptions(t: TFunction, wallets: WalletMap): Option[] { + const { categorised, uncategorised, categoryCount } + = getWalletOptions(wallets); + + const sortedCategories = Object.keys(categorised); + sortedCategories.sort((a, b) => a.localeCompare(b, undefined, { + sensitivity: "base", + numeric: true + })); + + 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 + } + ]; +} diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx index 771b20c..c845875 100644 --- a/src/pages/dev/DevPage.tsx +++ b/src/pages/dev/DevPage.tsx @@ -7,6 +7,7 @@ import { ImportBackupModal } from "../backup/ImportBackupModal"; import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; +import { AddressPicker } from "@comp/addresses/AddressPicker"; import { useWallets, deleteWallet } from "@wallets"; @@ -33,6 +34,12 @@

+ +

+ + +

+ {/* Delete all wallets with zero balance */}