diff --git a/.vscode/settings.json b/.vscode/settings.json index af3c02a..0eb8003 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,6 +54,7 @@ "readonly", "serialisable", "serialised", + "shallowequal", "singleline", "submenu", "summarising", diff --git a/package.json b/package.json index 5e9cc16..4e9236c 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "react-world-flags": "^1.4.0", "redux": "^4.0.5", "semver": "^7.3.4", + "shallowequal": "^1.1.0", "spu-md5": "0.0.4", "typesafe-actions": "^5.1.0", "uuid": "^8.3.2", @@ -103,6 +104,7 @@ "@types/react-router-dom": "^5.1.7", "@types/react-timeago": "^4.1.2", "@types/semver": "^7.3.4", + "@types/shallowequal": "^1.1.1", "@types/uuid": "^8.3.0", "@types/webpack-env": "^1.16.0", "@typescript-eslint/eslint-plugin": "4.15.3-alpha.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 004303b..20594c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,7 @@ react-world-flags: 1.4.0_react@17.0.1 redux: 4.0.5 semver: 7.3.4 + shallowequal: 1.1.0 spu-md5: 0.0.4 typesafe-actions: 5.1.0 uuid: 8.3.2 @@ -62,6 +63,7 @@ '@types/react-router-dom': 5.1.7 '@types/react-timeago': 4.1.2 '@types/semver': 7.3.4 + '@types/shallowequal': 1.1.1 '@types/uuid': 8.3.0 '@types/webpack-env': 1.16.0 '@typescript-eslint/eslint-plugin': 4.15.3-alpha.17_e20b0dcb4d72879efd3787e2a56fd748 @@ -2286,6 +2288,10 @@ dev: true resolution: integrity: sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ== + /@types/shallowequal/1.1.1: + dev: true + resolution: + integrity: sha512-Lhni3aX80zbpdxRuWhnuYPm8j8UQaa571lHP/xI4W+7BAFhSIhRReXnqjEgT/XzPoXZTJkCqstFMJ8CZTK6IlQ== /@types/source-list-map/0.1.2: dev: true resolution: @@ -14055,6 +14061,7 @@ '@types/react-router-dom': ^5.1.7 '@types/react-timeago': ^4.1.2 '@types/semver': ^7.3.4 + '@types/shallowequal': ^1.1.1 '@types/uuid': ^8.3.0 '@types/webpack-env': ^1.16.0 '@typescript-eslint/eslint-plugin': 4.15.3-alpha.17 @@ -14105,6 +14112,7 @@ redux-devtools-extension: ^2.13.9 rimraf: ^3.0.2 semver: ^7.3.4 + shallowequal: ^1.1.0 spu-md5: 0.0.4 typesafe-actions: ^5.1.0 typescript: 4.1.5 diff --git a/public/locales/en.json b/public/locales/en.json index b6ded9f..e3da0a0 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -844,7 +844,33 @@ "placeholder": "Choose a name", "placeholderMultiple": "Choose names", + "buttonAll": "All", + "warningTotalLimit": "You seem to have more than 1,000 names, which is not yet supported in KristWeb v2. Please post an issue on GitHub.", "errorLookup": "There was an error fetching the names. See the console for details." + }, + + "nameTransfer": { + "modalTitle": "Transfer names", + + "labelNames": "Names", + "labelRecipient": "Recipient", + + "buttonSubmit": "Transfer names", + + "errorNameRequired": "At least one name is required.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "Your wallet could not be decrypted.", + "errorParameterNames": "Invalid names.", + "errorParameterRecipient": "Invalid recipient.", + "errorNameNotFound": "The name \"{{name}}\" could not be found.", + "errorUnknown": "Unknown error transferring names. See console for details." + }, + + "noNamesResult": { + "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" } } diff --git a/src/components/results/NoWalletsResult.tsx b/src/components/results/NoWalletsResult.tsx index f8c5514..677df1f 100644 --- a/src/components/results/NoWalletsResult.tsx +++ b/src/components/results/NoWalletsResult.tsx @@ -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 { Dispatch, SetStateAction} from "react"; +import { Dispatch, SetStateAction } from "react"; import classNames from "classnames"; import { Button, Modal } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; diff --git a/src/global/LocaleContext.tsx b/src/global/LocaleContext.tsx index 60b1828..4ffeee2 100644 --- a/src/global/LocaleContext.tsx +++ b/src/global/LocaleContext.tsx @@ -19,7 +19,7 @@ export const TimeagoFormatterContext = createContext(undefined); export const LocaleContext: FC = ({ children }): JSX.Element => { - const { t, i18n } = useTranslation(); + const { i18n } = useTranslation(); const langCode = i18n.language; const languages = getLanguages(); const lang = languages?.[langCode]; diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx index 5a63c02..8585f50 100644 --- a/src/pages/dev/DevPage.tsx +++ b/src/pages/dev/DevPage.tsx @@ -6,9 +6,8 @@ import { PageLayout } from "@layout/PageLayout"; import { ImportBackupModal } from "../backup/ImportBackupModal"; -import { SendTransactionModal } from "../transactions/send/SendTransactionModal"; import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; -import { NamePicker } from "@pages/names/mgmt/NamePicker"; +import { NameTransferModal } from "@pages/names/mgmt/NameTransferModal"; import { useWallets, deleteWallet } from "@wallets"; @@ -17,7 +16,7 @@ export function DevPage(): JSX.Element { const [importVisible, setImportVisible] = useState(false); - const [sendTXVisible, setSendTXVisible] = useState(false); + const [transferNameVisible, setSendNameVisible] = useState(false); const { wallets } = useWallets(); return - {/* Open send tx modal */} - setSendTXVisible(true)}> - + {/* Open transfer name modal */} + setSendNameVisible(true)}> + - - -

- - +

diff --git a/src/pages/names/mgmt/NamePicker.tsx b/src/pages/names/mgmt/NamePicker.tsx index 5d8835f..f4e71bd 100644 --- a/src/pages/names/mgmt/NamePicker.tsx +++ b/src/pages/names/mgmt/NamePicker.tsx @@ -1,8 +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 { useState, useEffect, useMemo, Dispatch, SetStateAction } from "react"; -import { Select, notification } from "antd"; +import { + useState, useEffect, useMemo, Dispatch, SetStateAction, Ref +} from "react"; +import { Select, Form, Input, Button, notification } from "antd"; +import { RefSelectProps } from "antd/lib/select"; import { useTranslation, TFunction } from "react-i18next"; @@ -14,6 +17,7 @@ import { lookupNames } from "@api/lookup"; import { useNameSuffix } from "@utils/currency"; +import shallowEqual from "shallowequal"; import { throttle, groupBy } from "lodash-es"; @@ -26,7 +30,7 @@ t: TFunction, nameSuffix: string, wallets: WalletAddressMap, - setOptions: Dispatch> + setOptions: Dispatch> ): Promise { debug("performing name fetch"); @@ -45,7 +49,7 @@ // Group the names into OptGroups per wallet. const options = Object.entries(groupBy(names, n => n.owner)) .map(([address, group]) => - getNameOptions(nameSuffix, wallets[address], group)) + getNameOptions(nameSuffix, wallets[address], group)); debug("got names:", names, options); setOptions(options); @@ -58,34 +62,37 @@ } } -function getNameOptions( - nameSuffix: string, - wallet: Wallet, - names: KristName[] -): JSX.Element { - const groupLabel = wallet.label || wallet.address; - - // Group by owning wallet - return - {/* Each individual name */} - {names.map(name => ( - - {name.name}.{nameSuffix} - - ))} - -} - interface Props { + formName: string; + label?: string; + tabIndex?: number; + + value?: string[]; + setValue?: (value: string[]) => void; + + filterOwner?: string; + multiple?: boolean; + allowAll?: boolean; + + inputRef?: Ref; } export function NamePicker({ - multiple + formName, + label, + tabIndex, + + value, + setValue, + + filterOwner, + + multiple, + allowAll, + + inputRef, + ...props }: Props): JSX.Element { const { t } = useTranslation(); @@ -95,10 +102,18 @@ throttle(_fetchNames, FETCH_THROTTLE, { leading: true }), []); const nameSuffix = useNameSuffix(); + // The actual list of available names (pre-filtered, not rendered yet) + const [nameOptions, setNameOptions] + = useState(null); + const [filteredOptions, setFilteredOptions] + = useState(null); + // Used for auto-refreshing the names if they happen to update const refreshID = useSelector((s: RootState) => s.node.lastOwnNameTransactionID); - const [nameOptions, setNameOptions] = useState(null); + // Whether or not to show the 'All' button for bulk name management. On by + // default. + const showAllButton = allowAll !== false && multiple !== false; // Fetch the name list on mount/when the address list changes, or when one of // our wallets receives a name transaction. @@ -109,33 +124,161 @@ ); fetchNames(t, nameSuffix, walletAddressMap, setNameOptions); - }, [joinedAddressList, nameSuffix, refreshID]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchNames, t, nameSuffix, refreshID, joinedAddressList]); - // TODO: wrap this in a Form.Item - return + + {/* Name select */} + + + + + {/* "All" button */} + {showAllButton &&
+ +
} +
+ ; +} + +interface NameOptionGroup { + key: string; + label: string; + names: NameOption[]; +} + +interface NameOption { + key: string; + value: string; + name: string; +} + +function getNameOptions( + nameSuffix: string, + wallet: Wallet, + names: KristName[] +): NameOptionGroup { + // Group by owning wallet + return { + key: wallet.address, + label: wallet.label || wallet.address, + + // Each individual name + names: names.map(name => ({ + key: name.name, + value: name.name, + name: name.name + "." + nameSuffix + })) + }; +} + +function renderNameOptions(group: NameOptionGroup): JSX.Element { + // Group by owning wallet + return + {/* Each individual name */} + {group.names.map(name => ( + + {name.name} + + ))} + ; } diff --git a/src/pages/names/mgmt/NameTransferModal.tsx b/src/pages/names/mgmt/NameTransferModal.tsx new file mode 100644 index 0000000..aa856e3 --- /dev/null +++ b/src/pages/names/mgmt/NameTransferModal.tsx @@ -0,0 +1,119 @@ +// 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, Form, notification } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { NamePicker } from "./NamePicker"; +import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; + +import awaitTo from "await-to-js"; + +interface FormValues { + names: string[]; + recipient: string; +} + +interface Props { + visible: boolean; + setVisible: Dispatch>; + + name?: string; +} + +export function NameTransferModal({ + visible, + setVisible, + name +}: Props): JSX.Element { + const { t } = useTranslation(); + + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + // Used to filter out names owned by the recipient + const [names, setNames] = useState(); + const [recipient, setRecipient] = useState(); + + 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; + } + + console.log(values); + } + + function onValuesChange(_: unknown, values: Partial) { + setNames(values.names || undefined); + setRecipient(values.recipient || undefined); + } + + function closeModal() { + setVisible(false); + form.resetFields(); + setNames(undefined); + setRecipient(undefined); + setSubmitting(false); + } + + return +
+ {/* Names */} + form.setFieldsValue({ names })} + + multiple + allowAll + /> + + {/* Recipient */} + + +
; +} diff --git a/src/pages/names/mgmt/NoNamesModal.tsx b/src/pages/names/mgmt/NoNamesModal.tsx new file mode 100644 index 0000000..1a9582f --- /dev/null +++ b/src/pages/names/mgmt/NoNamesModal.tsx @@ -0,0 +1,44 @@ +// 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 classNames from "classnames"; +import { Modal } from "antd"; + +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; + +interface Props { + visible?: boolean; + setVisible?: Dispatch>; + className?: string; +} + +export function NoWalletsModal({ + className, + visible, + setVisible +}: Props): JSX.Element { + const { t } = useTranslation(); + const history = useHistory(); + + const classes = classNames("kw-no-names-modal", className); + + return { + setVisible?.(false); + history.push("/me/names"); + }} + okText={t("noNamesResult.button")} + + onCancel={() => setVisible?.(false)} + cancelText={t("dialog.cancel")} + > + {t("noNamesResult.subTitle")} + ; +} diff --git a/src/pages/whatsnew/WhatsNewCard.tsx b/src/pages/whatsnew/WhatsNewCard.tsx index 057930c..2874a75 100644 --- a/src/pages/whatsnew/WhatsNewCard.tsx +++ b/src/pages/whatsnew/WhatsNewCard.tsx @@ -3,7 +3,7 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { FC } from "react"; import classNames from "classnames"; -import { Card, Skeleton, Row, Tag } from "antd"; +import { Card, Skeleton, Tag } from "antd"; import { useTranslation } from "react-i18next";