diff --git a/public/locales/en.json b/public/locales/en.json index 5aabb33..e2387fd 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -515,7 +515,9 @@ "tableTotalEmpty": "No names", "resultInvalidTitle": "Invalid address", - "resultInvalid": "That does not look like a valid Krist address." + "resultInvalid": "That does not look like a valid Krist address.", + + "purchaseButton": "Purchase name" }, "name": { @@ -925,5 +927,37 @@ "title": "No names yet", "subTitle": "You currently have no names in any of your wallets saved in KristWeb, so there is nothing to see here yet. Would you like to purchase a name?", "button": "Purchase name" + }, + + "namePurchase": { + "modalTitle": "Purchase name", + + "nameCost": "Cost to purchase: <1 />", + + "labelWallet": "Wallet", + "labelName": "Name", + "placeholderName": "Name", + + "buttonSubmit": "Purchase (<1 />)", + + "errorNameRequired": "Name is required.", + "errorInvalidName": "Invalid name.", + "errorNameTooLong": "Name is too long.", + "errorNameTaken": "That name is already taken!", + "errorInsufficientFunds": "Your wallet does not have enough funds to purchase a name.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "The wallet \"{{address}}\" could not be decrypted.", + "errorUnknown": "Unknown error purchasing name. See console for details.", + "errorNotificationTitle": "Name purchase failed", + + "successMessage": "Name purchased successfully", + "successNotificationButton": "View name", + + "nameAvailable": "Name is available!" + }, + + "purchaseKrist": { + "modalTitle": "Purchase Krist" } } diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx index 77b5ac8..11e63e6 100644 --- a/src/components/addresses/picker/AddressPicker.tsx +++ b/src/components/addresses/picker/AddressPicker.tsx @@ -1,10 +1,11 @@ // 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 { useMemo, Ref } from "react"; +import React, { useMemo, Ref } from "react"; import classNames from "classnames"; import { AutoComplete, Form } from "antd"; import { Rule } from "antd/lib/form"; +import { ValidateStatus } from "antd/lib/form/FormItem"; import { RefSelectProps } from "antd/lib/select"; import { useTranslation } from "react-i18next"; @@ -33,6 +34,9 @@ noNames?: boolean; nameHint?: boolean; + validateStatus?: ValidateStatus; + help?: React.ReactNode; + suppressUpdates?: boolean; className?: string; @@ -50,6 +54,9 @@ noNames, nameHint, + validateStatus, + help, + suppressUpdates, className, @@ -131,6 +138,10 @@ // blank input validateFirst + // Allow the host form to show its own errors + validateStatus={validateStatus} + help={help} + rules={[ { required: true, message: walletsOnly ? t("addressPicker.errorWalletRequired") diff --git a/src/components/addresses/picker/PickerHints.tsx b/src/components/addresses/picker/PickerHints.tsx index f6ff406..c92c9d3 100644 --- a/src/components/addresses/picker/PickerHints.tsx +++ b/src/components/addresses/picker/PickerHints.tsx @@ -32,6 +32,8 @@ hasExactName?: boolean, suppressUpdates?: boolean ): JSX.Element | null { + debug("using picker hints for %s", value); + // Used for clean-up const isMounted = useRef(true); @@ -111,6 +113,7 @@ if (!value) { setFoundAddress(undefined); setFoundName(undefined); + setValidAddress(undefined); return; } @@ -139,6 +142,8 @@ // Clean up the debounced function when unmounting useEffect(() => { + isMounted.current = true; + return () => { debug("unmounting address picker hint"); isMounted.current = false; diff --git a/src/components/krist/KristValue.less b/src/components/krist/KristValue.less index 4a791be..f2330cd 100644 --- a/src/components/krist/KristValue.less +++ b/src/components/krist/KristValue.less @@ -44,3 +44,8 @@ } } } + +// The currency symbol appears too dark when inside a button +.ant-btn.ant-btn-primary .krist-value .anticon svg { + color: fade(@text-color, 70%); +} diff --git a/src/components/krist/KristValue.tsx b/src/components/krist/KristValue.tsx index 9cd30cc..df2495e 100644 --- a/src/components/krist/KristValue.tsx +++ b/src/components/krist/KristValue.tsx @@ -1,6 +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 React from "react"; import classNames from "classnames"; import { useSelector } from "react-redux"; @@ -11,6 +12,7 @@ import "./KristValue.less"; interface OwnProps { + icon?: React.ReactNode; value?: number; long?: boolean; hideNullish?: boolean; @@ -19,7 +21,15 @@ } type Props = React.HTMLProps & OwnProps; -export const KristValue = ({ value, long, hideNullish, green, highlightZero, ...props }: Props): JSX.Element | null => { +export const KristValue = ({ + icon, + value, + long, + hideNullish, + green, + highlightZero, + ...props +}: Props): JSX.Element | null => { const currencySymbol = useSelector((s: RootState) => s.node.currency.currency_symbol); if (hideNullish && (value === undefined || value === null)) return null; @@ -31,7 +41,7 @@ return ( - {(currencySymbol || "KST") === "KST" && } + {icon || ((currencySymbol || "KST") === "KST" && )} {(value || 0).toLocaleString()} {long && {currencySymbol || "KST"}} diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx index 0cd78d9..a5b3e21 100644 --- a/src/global/AppServices.tsx +++ b/src/global/AppServices.tsx @@ -8,6 +8,7 @@ import { SyncMOTD } from "./ws/SyncMOTD"; import { AppHotkeys } from "./AppHotkeys"; import { StorageBroadcast } from "./StorageBroadcast"; +import { PurchaseKristHandler } from "./PurchaseKrist"; export function AppServices(): JSX.Element { return <> @@ -17,5 +18,7 @@ + + ; } diff --git a/src/global/PurchaseKrist.tsx b/src/global/PurchaseKrist.tsx new file mode 100644 index 0000000..f497767 --- /dev/null +++ b/src/global/PurchaseKrist.tsx @@ -0,0 +1,89 @@ +// 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, Dispatch, SetStateAction } from "react"; +import { Modal, Row, Col, Button } from "antd"; + +import { useTFns } from "@utils/i18n"; + +import { KristValue } from "@comp/krist/KristValue"; + +import { GlobalHotKeys } from "react-hotkeys"; + +interface Props { + visible: boolean; + setVisible: Dispatch>; +} + +interface PurchaseOption { + image?: string; + source: number; + krist: number; +} + +const VALUES: PurchaseOption[][] = [ + [ + { source: 5000, krist: 50 }, { source: 10000, krist: 100 }, + { source: 25000, krist: 250 }, { source: 50000, krist: 500 } + ], + [ + { source: 100000, krist: 1000 }, { source: 250000, krist: 2500 }, + { source: 500000, krist: 5000 }, { source: 500000000, krist: 5000000 } + ] +]; + +export function PurchaseKrist({ + visible, + setVisible +}: Props): JSX.Element { + const { t, tStr } = useTFns("purchaseKrist."); + + return setVisible(false)}> + {t("dialog.close")} + } + onCancel={() => setVisible(false)} + > + {VALUES.map((row, i) => + {row.map((option, i) => ( + +
+ +

+ +
+ + ))} +
)} +
; +} + +export function PurchaseKristHandler(): JSX.Element { + const [visible, setVisible] = useState(false); + + return <> + + + setVisible(true) }} + /> + ; +} diff --git a/src/krist/api/names.ts b/src/krist/api/names.ts index 223cb2d..9df9d27 100644 --- a/src/krist/api/names.ts +++ b/src/krist/api/names.ts @@ -1,10 +1,12 @@ // 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 { TranslatedError } from "@utils/i18n"; + import * as api from "."; import { AuthFailedError } from "@api/AuthFailed"; -import { ValidDecryptedAddresses } from "@wallets"; +import { ValidDecryptedAddresses, Wallet, decryptWallet } from "@wallets"; import Debug from "debug"; const debug = Debug("kristweb:api-names"); @@ -67,3 +69,20 @@ onProgress?.(); } } + +export async function purchaseName( + masterPassword: string, + wallet: Wallet, + name: string +): Promise { + // Attempt to decrypt the wallet to get the privatekey + const decrypted = await decryptWallet(masterPassword, wallet); + if (!decrypted) + throw new TranslatedError("namePurchase.errorWalletDecrypt"); + const { privatekey } = decrypted; + + await api.post( + `names/${encodeURIComponent(name)}`, + { privatekey } + ); +} diff --git a/src/pages/names/NamePage.tsx b/src/pages/names/NamePage.tsx index 025fefe..b610160 100644 --- a/src/pages/names/NamePage.tsx +++ b/src/pages/names/NamePage.tsx @@ -27,6 +27,7 @@ import { NameButtonRow } from "./NameButtonRow"; import { NameTransactionsCard } from "./NameTransactionsCard"; +import { NameEditModalLink } from "./mgmt/NameEditModalLink"; import "./NamePage.less"; @@ -130,9 +131,15 @@ titleKey="name.aRecord" titleExtra={myWallet && <> - - - + + + + + } value={} diff --git a/src/pages/names/NamesPage.tsx b/src/pages/names/NamesPage.tsx index ac5d6a7..73c71f7 100644 --- a/src/pages/names/NamesPage.tsx +++ b/src/pages/names/NamesPage.tsx @@ -2,6 +2,8 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { useState, useMemo } from "react"; +import { Button } from "antd"; +import { TagsOutlined } from "@ant-design/icons"; import { useTranslation, TFunction } from "react-i18next"; import { useParams } from "react-router-dom"; @@ -14,6 +16,8 @@ import { NoWalletsResult } from "@comp/results/NoWalletsResult"; import { NamesTable } from "./NamesTable"; +import { NamePurchaseModalLink } from "./mgmt/NamePurchaseModalLink"; + import { useWallets } from "@wallets"; import { useBooleanSetting } from "@utils/settings"; import { useLinkedPagination } from "@utils/table"; @@ -113,7 +117,14 @@ // For an address's name listing, show that address in the subtitle. subTitle={subTitle} - extra={paginationComponent} + // Purchase name button + extra={<> + + + + } > {(() => { if (error) diff --git a/src/pages/names/mgmt/NameEditModal.tsx b/src/pages/names/mgmt/NameEditModal.tsx index 36bb76c..93c779c 100644 --- a/src/pages/names/mgmt/NameEditModal.tsx +++ b/src/pages/names/mgmt/NameEditModal.tsx @@ -17,7 +17,7 @@ import { useAuthFailedModal } from "@api/AuthFailed"; import { NameOption, fetchNames, buildLUT } from "./lookupNames"; -import { handleError } from "./handleErrors"; +import { handleEditError } from "./handleErrors"; import { lockNameTable, NameTableLock } from "../tableLock"; import { useNameEditForm } from "./NameEditForm"; @@ -75,8 +75,8 @@ = useEditProgress(tFns); // Wrap the handleError function - const onError = handleError.bind( - handleError, + const onError = handleEditError.bind( + handleEditError, tFns, showAuthFailed, walletAddressMap ); diff --git a/src/pages/names/mgmt/NamePurchaseModal.tsx b/src/pages/names/mgmt/NamePurchaseModal.tsx new file mode 100644 index 0000000..b2f4580 --- /dev/null +++ b/src/pages/names/mgmt/NamePurchaseModal.tsx @@ -0,0 +1,253 @@ +// 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, Dispatch, SetStateAction } from "react"; +import { Modal, Form, Input, Typography, Button, notification } from "antd"; + +import { Trans } from "react-i18next"; +import { useTFns } from "@utils/i18n"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; +import { store } from "@app"; + +import { Link } from "react-router-dom"; + +import { useWallets, Wallet } from "@wallets"; +import { + useNameSuffix, BARE_NAME_REGEX, MAX_NAME_LENGTH, isValidName +} from "@utils/currency"; + +import { checkName } from "./checkName"; +import { handlePurchaseError } from "./handleErrors"; +import { purchaseName } from "@api/names"; +import { useAuthFailedModal } from "@api/AuthFailed"; + +import { KristValue } from "@comp/krist/KristValue"; +import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; + +import awaitTo from "await-to-js"; +import { throttle } from "lodash-es"; + +const { Text } = Typography; + +interface FormValues { + address: string; + name: string; +} + +interface Props { + visible: boolean; + setVisible: Dispatch>; +} + +const CHECK_THROTTLE = 300; + +export function NamePurchaseModal({ + visible, + setVisible +}: Props): JSX.Element { + const tFns = useTFns("namePurchase."); + const { t, tStr, tKey, tErr } = tFns; + + const [form] = Form.useForm(); + + // Used to perform extra validation + const [address, setAddress] = useState(""); + const [name, setName] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [nameAvailable, setNameAvailable] = useState(); + const checkNameAvailable = + useMemo(() => throttle(checkName, CHECK_THROTTLE, { leading: true }), []); + + // Modal used when auth fails + const { showAuthFailed, authFailedContextHolder } = useAuthFailedModal(); + // Context for translation in the success notification + const [notif, notifContextHolder] = notification.useNotification(); + + // Used to format the form and determine whether or not the name can be + // afforded by the chosen wallet + const nameSuffix = useNameSuffix(); + const nameCost = useSelector((s: RootState) => s.node.constants.name_cost); + + const { walletAddressMap } = useWallets(); + + // Used to show the validate message if the name can't be afforded + const canAffordName = (walletAddressMap[address]?.balance || 0) >= nameCost; + + // Look up name availability when the input changes + useEffect(() => { + setNameAvailable(undefined); + + if (name && isValidName(name)) + checkNameAvailable(name, setNameAvailable); + }, [name, checkNameAvailable]); + + // Take the form values and known wallet and purchase the name + async function handleSubmit(wallet: Wallet, name: string): Promise { + const masterPassword = store.getState().masterPassword.masterPassword; + if (!masterPassword) throw tErr("errorWalletDecrypt"); + + // Verify the name can be afforded once again + if ((wallet.balance || 0) < nameCost) + throw tErr("errorInsufficientFunds"); + + // Perform the purchase + await purchaseName(masterPassword, wallet, name); + + // Success! Show notification and close modal + notif.success({ + message: t(tKey("successMessage")), + btn: + }); + + setSubmitting(false); + closeModal(); + } + + async function onSubmit() { + setSubmitting(true); + + // Get the form values + const [err, values] = await awaitTo(form.validateFields()); + if (err || !values) { + // Validation errors are handled by the form + setSubmitting(false); + return; + } + const { address, name } = values; + + // Fetch the wallet from the address field and verify it actually exists + const currentWallet = walletAddressMap[address]; + if (!currentWallet) throw tErr("errorWalletGone"); + + // Begin the purchase + handleSubmit(currentWallet, name) + .catch(err => + handlePurchaseError(tFns, showAuthFailed, currentWallet, err)) + .finally(() => setSubmitting(false)); + } + + function onValuesChange(values: Partial) { + setAddress(values.address || ""); + setName(values.name || ""); + } + + function closeModal() { + form.resetFields(); + setVisible(false); + setAddress(""); + setName(""); + setSubmitting(false); + setNameAvailable(undefined); + } + + const modal = + Purchase () + } + okButtonProps={submitting ? { loading: true } : undefined} + + onCancel={closeModal} + cancelText={t("dialog.cancel")} + destroyOnClose + > +
+ {/* Name cost */} +
+ + Cost to purchase: + +
+ + {/* Wallet/address */} + + + {/* Name */} + + + {tStr("errorNameTaken")} + : (nameAvailable + // Name available + ? + {tStr("nameAvailable")} + + : undefined)} + + validateFirst + rules={[ + { required: true, message: tStr("errorNameRequired") }, + { max: MAX_NAME_LENGTH, message: tStr("errorNameTooLong") }, + { pattern: BARE_NAME_REGEX, message: tStr("errorInvalidName") }, + ]} + > + + + + + .{nameSuffix} + + + + +
; + + return <> + {modal} + + {authFailedContextHolder} + {notifContextHolder} + ; +} + +export function NotifSuccessButton({ name }: { name: string }): JSX.Element { + const { tStr } = useTFns("namePurchase."); + + return + + ; +} diff --git a/src/pages/names/mgmt/NamePurchaseModalLink.tsx b/src/pages/names/mgmt/NamePurchaseModalLink.tsx new file mode 100644 index 0000000..17690f9 --- /dev/null +++ b/src/pages/names/mgmt/NamePurchaseModalLink.tsx @@ -0,0 +1,22 @@ +// 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 { FC, useState } from "react"; + +import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; +import { NamePurchaseModal } from "./NamePurchaseModal"; + +export const NamePurchaseModalLink: FC = ({ children }): JSX.Element => { + const [modalVisible, setModalVisible] = useState(false); + + return <> + setModalVisible(true)}> + {children} + + + + ; +}; diff --git a/src/pages/names/mgmt/checkName.ts b/src/pages/names/mgmt/checkName.ts new file mode 100644 index 0000000..815d374 --- /dev/null +++ b/src/pages/names/mgmt/checkName.ts @@ -0,0 +1,24 @@ +// 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 { Dispatch, SetStateAction } from "react"; + +import * as api from "@api"; + +interface CheckNameResponse { + available: boolean; +} + +export async function checkName( + name: string, + setNameAvailable: Dispatch> +): Promise { + try { + const url = `names/check/${encodeURIComponent(name)}`; + const { available } = await api.get(url); + setNameAvailable(available); + } catch (err) { + console.error(err); + setNameAvailable(undefined); + } +} diff --git a/src/pages/names/mgmt/handleErrors.ts b/src/pages/names/mgmt/handleErrors.ts index a988c79..4f4e9a0 100644 --- a/src/pages/names/mgmt/handleErrors.ts +++ b/src/pages/names/mgmt/handleErrors.ts @@ -8,10 +8,10 @@ import { APIError } from "@api"; import { AuthFailedError, ShowAuthFailedFn } from "@api/AuthFailed"; -import { WalletAddressMap } from "@wallets"; +import { WalletAddressMap, Wallet } from "@wallets"; // Convert API errors to friendlier errors -export async function handleError( +export async function handleEditError( { t, tKey, tStr, tErr }: TFns, showAuthFailed: ShowAuthFailedFn, walletAddressMap: WalletAddressMap, @@ -46,3 +46,31 @@ console.error(err); onError(err); } + +export async function handlePurchaseError( + { t, tKey, tStr, tErr }: TFns, + showAuthFailed: ShowAuthFailedFn, + wallet: Wallet, + err: Error +): Promise { + const onError = (err: Error) => notification.error({ + message: tStr("errorNotificationTitle"), + description: translateError(t, err, tKey("errorUnknown")) + }); + + switch (err.message) { + case "missing_parameter": + case "invalid_parameter": + return onError(tErr("errorInvalidName")); + case "name_taken": + return onError(tErr("errorNameTaken")); + case "insufficient_funds": + return onError(tErr("errorInsufficientFunds")); + case "auth_failed": + return showAuthFailed(wallet); + } + + // Pass through any other unknown errors + console.error(err); + onError(err); +} diff --git a/src/pages/transactions/send/AmountInput.tsx b/src/pages/transactions/send/AmountInput.tsx index 6baebcc..11802be 100644 --- a/src/pages/transactions/send/AmountInput.tsx +++ b/src/pages/transactions/send/AmountInput.tsx @@ -44,7 +44,7 @@ {/* Prepend the Krist symbol if possible. Note that ant's InputNumber * doesn't support addons, so this has to be done manually. */} {(currency_symbol || "KST") === "KST" && ( - + )} @@ -83,7 +83,7 @@ {/* Currency suffix */} - + {currency_symbol || "KST"} diff --git a/src/pages/transactions/send/SendTransactionForm.less b/src/pages/transactions/send/SendTransactionForm.less deleted file mode 100644 index 95ae29f..0000000 --- a/src/pages/transactions/send/SendTransactionForm.less +++ /dev/null @@ -1,22 +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"; - -.send-transaction-form { - .ant-input-group-addon { - border: none; - flex: 0; - width: auto; - vertical-align: middle; - line-height: @input-height-base; - height: @input-height-base; - - &.currency-prefix .anticon { - color: @kw-text-tertiary; - font-size: 80%; - vertical-align: middle; - line-height: @input-height-base; - } - } -} diff --git a/src/pages/transactions/send/SendTransactionForm.tsx b/src/pages/transactions/send/SendTransactionForm.tsx index b246209..13a9a18 100644 --- a/src/pages/transactions/send/SendTransactionForm.tsx +++ b/src/pages/transactions/send/SendTransactionForm.tsx @@ -27,8 +27,6 @@ import awaitTo from "await-to-js"; -import "./SendTransactionForm.less"; - import Debug from "debug"; const debug = Debug("kristweb:send-transaction-form"); diff --git a/src/style/components.less b/src/style/components.less index ac7fa9d..7e65fee 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -127,6 +127,22 @@ } } +.ant-input-group-addon.kw-fake-addon { + border: none; + flex: 0; + width: auto; + vertical-align: middle; + line-height: @input-height-base; + height: @input-height-base; + + .anticon { + color: @kw-text-tertiary; + font-size: 80%; + vertical-align: middle; + line-height: @input-height-base; + } +} + .ant-input-group.ant-input-group-compact .ant-btn { border-left: 1px solid @modal-content-bg; @@ -184,17 +200,10 @@ } } -.text-small { - font-size: @font-size-sm; -} - -.text-green { - color: @kw-green; -} - -.text-orange { - color: @orange-7; -} +.text-small { font-size: @font-size-sm; } +.text-green { color: @kw-green; } +.text-orange { color: @orange-7; } +.text-red { color: @kw-red; } .nyi { text-decoration: line-through; diff --git a/src/utils/currency.ts b/src/utils/currency.ts index 68c817b..c610587 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -11,6 +11,11 @@ // ----------------------------------------------------------------------------- // NAMES // ----------------------------------------------------------------------------- +export const BARE_NAME_REGEX = /^([a-z0-9]{1,64})$/; +export const MAX_NAME_LENGTH = 64; +export const isValidName = + (name: string): boolean => BARE_NAME_REGEX.test(name); + // Cheap way to avoid RegExp DoS const MAX_NAME_SUFFIX_LENGTH = 6; const _cleanNameSuffix = (nameSuffix: string | undefined | null): string => {