diff --git a/public/locales/en.json b/public/locales/en.json index 255d542..a0f35e8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -504,6 +504,12 @@ "columnARecord": "A Record", "columnUnpaid": "Unpaid Blocks", + "actions": "Actions", + "actionsSendKrist": "Send Krist", + "actionsTransferKrist": "Transfer Krist", + "actionsUpdateARecord": "Update A record", + "actionsTransferName": "Transfer name", + "tableTotal": "{{count, number}} name", "tableTotal_plural": "{{count, number}} names", "tableTotalEmpty": "No names", @@ -883,6 +889,32 @@ "successDescription_plural": "Transferred <1>{{count, number}} names to <3 />." }, + "nameUpdate": { + "modalTitle": "Update names", + + "labelNames": "Names", + "labelARecord": "A record", + "placeholderARecord": "A record (optional)", + + "buttonSubmit": "Update names", + + "errorNameRequired": "At least one name is required.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "The wallet \"{{address}}\" could not be decrypted.", + "errorParameterNames": "Invalid names.", + "errorParameterARecord": "Invalid A record.", + "errorNameNotFound": "One or more names could not be found.", + "errorNotNameOwner": "You are not the owner of one or more names.", + "errorUnknown": "Unknown error updating names. See console for details.", + "errorNotificationTitle": "Name transfer failed", + + "successMessage": "Name updated successfully", + "successMessage_plural": "Names updated successfully", + "successDescription": "Updated <1>{{count, number}} names.", + "successDescription_plural": "Updated <1>{{count, number}} names." + }, + "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?", diff --git a/src/krist/api/AuthFailed.tsx b/src/krist/api/AuthFailed.tsx index 6b83773..610d672 100644 --- a/src/krist/api/AuthFailed.tsx +++ b/src/krist/api/AuthFailed.tsx @@ -18,10 +18,12 @@ } } +export type ShowAuthFailedFn = (wallet: Wallet) => void; + interface AuthFailedModalHookResponse { authFailedModal: Omit; authFailedContextHolder: ReactElement; - showAuthFailed: (wallet: Wallet) => void; + showAuthFailed: ShowAuthFailedFn; } /** diff --git a/src/krist/api/index.ts b/src/krist/api/index.ts index 44d0643..5f90803 100644 --- a/src/krist/api/index.ts +++ b/src/krist/api/index.ts @@ -56,10 +56,20 @@ return data; } +/** Generates a stringified JSON POST body, with the appropriate Content-Type + * headers for the request. */ +export const buildBody = (value: any): RequestOptions => ({ + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(value) +}); + export const get = (endpoint: string, options?: RequestOptions): Promise> => request("GET", endpoint, options); -export const post = (endpoint: string, options?: RequestOptions): Promise> => - request("POST", endpoint, options); +export const post = (endpoint: string, body?: any, options?: RequestOptions): Promise> => + request("POST", endpoint, { + ...buildBody(body), + ...options + }); /** Re-usable syncNode hook, usually for refreshing things when the syncNode * changes. */ diff --git a/src/krist/api/names.ts b/src/krist/api/names.ts index e01013a..ff93b91 100644 --- a/src/krist/api/names.ts +++ b/src/krist/api/names.ts @@ -9,35 +9,53 @@ import Debug from "debug"; const debug = Debug("kristweb:api-names"); +interface PartialName { + name: string; + owner: string; +} + +/** Convert auth_failed errors to AuthFailedError so the modal can display the + * correct address. */ +async function wrapAuthFailedError(name: PartialName, err: Error) { + if (err.message === "auth_failed") + throw new AuthFailedError(err.message, name.owner); + else + throw err; +} + export async function transferNames( decryptedAddresses: ValidDecryptedAddresses, - names: { name: string; owner: string }[], + names: PartialName[], recipient: string ): Promise { for (const name of names) { const { privatekey } = decryptedAddresses[name.owner]; + const onError = wrapAuthFailedError.bind(undefined, name); - try { - debug("transferring name %s from %s to %s", - name.name, name.owner, recipient); + debug("transferring name %s from %s to %s", + name.name, name.owner, recipient); - await api.post( - `/names/${encodeURIComponent(name.name)}/transfer`, - { - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - address: recipient, - privatekey - }) - } - ); - } catch (err) { - // Convert auth_failed errors to AuthFailedError so the modal can display - // the correct address - if (err.message === "auth_failed") - throw new AuthFailedError(err.message, name.owner); - else - throw err; - } + await api.post( + `/names/${encodeURIComponent(name.name)}/transfer`, + { address: recipient, privatekey } + ).catch(onError); + } +} + +export async function updateNames( + decryptedAddresses: ValidDecryptedAddresses, + names: PartialName[], + aRecord?: string | null +): Promise { + for (const name of names) { + const { privatekey } = decryptedAddresses[name.owner]; + const onError = wrapAuthFailedError.bind(undefined, name); + + debug("updating name %s a record to %s", name.name, aRecord); + + await api.post( + `/names/${encodeURIComponent(name.name)}/update`, + { a: aRecord?.trim() || null, privatekey } + ).catch(onError); } } diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx index 8585f50..5ebcee6 100644 --- a/src/pages/dev/DevPage.tsx +++ b/src/pages/dev/DevPage.tsx @@ -7,7 +7,6 @@ import { ImportBackupModal } from "../backup/ImportBackupModal"; import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; -import { NameTransferModal } from "@pages/names/mgmt/NameTransferModal"; import { useWallets, deleteWallet } from "@wallets"; @@ -16,7 +15,6 @@ export function DevPage(): JSX.Element { const [importVisible, setImportVisible] = useState(false); - const [transferNameVisible, setSendNameVisible] = useState(false); const { wallets } = useWallets(); return - {/* Open transfer name modal */} - setSendNameVisible(true)}> - - - -

{/* Delete all wallets with zero balance */} diff --git a/src/pages/names/NameButtonRow.tsx b/src/pages/names/NameButtonRow.tsx index 3538f50..73a1891 100644 --- a/src/pages/names/NameButtonRow.tsx +++ b/src/pages/names/NameButtonRow.tsx @@ -9,6 +9,7 @@ import { KristName } from "@api/types"; import { Wallet } from "@wallets"; import { SendTransactionModalLink } from "@comp/transactions/SendTransactionModalLink"; +import { NameEditModalLink } from "./mgmt/NameEditModalLink"; interface Props { name: KristName; @@ -16,9 +17,7 @@ myWallet?: Wallet; } -// TODO: _name is currently unused, but will be used when these buttons can -// actually do something -export function NameButtonRow({ name: _name, nameWithSuffix, myWallet }: Props): JSX.Element { +export function NameButtonRow({ name, nameWithSuffix, myWallet }: Props): JSX.Element { const { t } = useTranslation(); return <> @@ -42,16 +41,27 @@ {/* If we're the name owner, show the management buttons */} {/* Update A record button */} {myWallet && ( - + + + )} {/* Transfer name button */} {myWallet && ( - + + + )} ; } diff --git a/src/pages/names/NamesTable.tsx b/src/pages/names/NamesTable.tsx index 5fad00c..9e22047 100644 --- a/src/pages/names/NamesTable.tsx +++ b/src/pages/names/NamesTable.tsx @@ -12,6 +12,9 @@ useMalleablePagination, useTableHistory, useDateColumnWidth } from "@utils/table"; +import { useWallets } from "@wallets"; +import { NameActions } from "./mgmt/NameActions"; + import { KristNameLink } from "@comp/names/KristNameLink"; import { ContextualAddress } from "@comp/addresses/ContextualAddress"; import { TransactionConciseMetadata } from "@comp/transactions/TransactionConciseMetadata"; @@ -50,6 +53,9 @@ const dateColumnWidth = useDateColumnWidth(); + // Used to change the actions depending on whether or not we own the name + const { walletAddressMap } = useWallets(); + // Fetch the names from the API, mapping the table options useEffect(() => { debug("looking up names for %s", addresses ? addresses.join(",") : "network"); @@ -163,6 +169,18 @@ width: dateColumnWidth, sorter: true + }, + + // Actions + { + key: "actions", + width: 100, // Force it to be minimum size + render: (_, record) => ( + + ) } ]} />; diff --git a/src/pages/names/mgmt/ARecordInput.tsx b/src/pages/names/mgmt/ARecordInput.tsx new file mode 100644 index 0000000..2c2a93c --- /dev/null +++ b/src/pages/names/mgmt/ARecordInput.tsx @@ -0,0 +1,32 @@ +// 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 { Form, Input } from "antd"; + +import { useTranslation } from "react-i18next"; + +const A_RECORD_REGEXP = /^[^\s.?#].[^\s]*/; + +export function ARecordInput(): JSX.Element { + const { t } = useTranslation(); + + return 255) + throw t("nameUpdate.errorParameterARecord"); + } + }]} + > + + ; +} diff --git a/src/pages/names/mgmt/ConfirmModal.tsx b/src/pages/names/mgmt/ConfirmModal.tsx new file mode 100644 index 0000000..4d2594c --- /dev/null +++ b/src/pages/names/mgmt/ConfirmModal.tsx @@ -0,0 +1,59 @@ +// 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, TFunction } from "react-i18next"; + +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; +import { ModalStaticFunctions } from "antd/lib/modal/confirm"; + +interface Props { + count: number; + recipient?: string; + allNamesCount: number; +} + +export function showConfirmModal( + t: TFunction, + confirmModal: Omit, + count: number, + allNamesCount: number, + recipient: string, + triggerSubmit: () => void, + setSubmitting: (value: boolean) => void, +): void { + confirmModal.confirm({ + title: t("nameTransfer.modalTitle"), + content: , + + okText: t("nameTransfer.buttonSubmit"), + onOk: triggerSubmit, + + cancelText: t("dialog.cancel"), + onCancel: () => setSubmitting(false) + }); +} + +// No 'Mode' necessary, this is only shown for transfers +function ConfirmModalContent({ + count, + recipient, + allNamesCount +}: Props): JSX.Element { + const { t } = useTranslation(); + + // Show the appropriate message, if this is all the owner's names + return = allNamesCount + ? "nameTransfer.warningAllNames" + : "nameTransfer.warningMultipleNames"} + count={count} + > + Are you sure you want to transfer {{ count }} names to + ? + ; +} diff --git a/src/pages/names/mgmt/NameActions.tsx b/src/pages/names/mgmt/NameActions.tsx new file mode 100644 index 0000000..3f5e8ee --- /dev/null +++ b/src/pages/names/mgmt/NameActions.tsx @@ -0,0 +1,82 @@ +// 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 { Button, Dropdown, Menu, Tooltip } from "antd"; +import { DownOutlined, SwapOutlined, SendOutlined, EditOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { KristName } from "@api/types"; +import { useNameSuffix } from "@utils/currency"; + +import { SendTransactionModalLink } from "@comp/transactions/SendTransactionModalLink"; +import { NameEditModalLink } from "./NameEditModalLink"; + +interface Props { + name: KristName; + isOwn: boolean; +} + +export function NameActions({ name, isOwn }: Props): JSX.Element { + const { t } = useTranslation(); + + const nameSuffix = useNameSuffix(); + const nameWithSuffix = `${name.name}.${nameSuffix}`; + + // The dropdown menu, used if we own the name + const buttonMenu = isOwn ? + {/* Transfer Krist button */} + + +
{t("names.actionsTransferKrist")}
+
+
+ + + + {/* Update A record */} + + +
{t("names.actionsUpdateARecord")}
+
+
+ + {/* Transfer name */} + + +
{t("names.actionsTransferName")}
+
+
+
: undefined; + + if (isOwn) { + // Actions dropdown (own name) + return + + ; + } else { + // Send transaction button (not own name) + return + + ; + } +} diff --git a/src/pages/names/mgmt/NameEditModal.tsx b/src/pages/names/mgmt/NameEditModal.tsx new file mode 100644 index 0000000..fde1bb0 --- /dev/null +++ b/src/pages/names/mgmt/NameEditModal.tsx @@ -0,0 +1,298 @@ +// 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 { useTFns } from "@utils/i18n"; + +import { + useWallets, useMasterPasswordOnly, + decryptAddresses, DecryptErrorGone, DecryptErrorFailed, + ValidDecryptedAddresses +} from "@wallets"; +import { useNameSuffix } from "@utils/currency"; + +import { transferNames, updateNames } from "@api/names"; +import { useAuthFailedModal } from "@api/AuthFailed"; + +import { NameOption, fetchNames, buildLUT } from "./lookupNames"; +import { handleError } from "./handleErrors"; + +import { NamePicker } from "./NamePicker"; +import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; +import { ARecordInput } from "./ARecordInput"; +import { showConfirmModal } from "./ConfirmModal"; +import { SuccessNotifContent } from "./SuccessNotifContent"; + +import awaitTo from "await-to-js"; + +export type Mode = "transfer" | "update"; + +interface FormValues { + names: string[]; + recipient?: string; + aRecord?: string; +} + +interface Props { + visible: boolean; + setVisible: Dispatch>; + + name?: string; + aRecord?: string | null; + mode: Mode; +} + +export function NameEditModal({ + visible, + setVisible, + name, + aRecord, + mode +}: Props): JSX.Element { + // Return translated strings with the correct prefix depending on the mode + const tFns = useTFns(mode === "transfer" ? "nameTransfer." : "nameUpdate."); + const { t, tKey, tStr, tErr } = tFns; + + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + // Used to filter out names owned by the recipient (for transfers) + const [names, setNames] = useState(); + const [recipient, setRecipient] = useState(); + + // Confirmation modal used for when transferring multiple names. + // This is created here to provide a translation context for the modal. + const [confirmModal, contextHolder] = Modal.useModal(); + // Modal used when auth fails + const { showAuthFailed, authFailedContextHolder } = useAuthFailedModal(); + // Context for translation in the success notification + const [notif, notifContextHolder] = notification.useNotification(); + + // Used to fetch the list of available names + const { walletAddressMap } = useWallets(); + const nameSuffix = useNameSuffix(); + // Used to decrypt the wallets for transfer/update + const masterPassword = useMasterPasswordOnly(); + + // Wrap the handleError function + const onError = handleError.bind( + handleError, + tFns, showAuthFailed, walletAddressMap + ); + + // Actually perform the bulk name edit + async function handleSubmit( + names: NameOption[], + recipient?: string, + aRecord?: string + ) { + if (!masterPassword) return; + + // Attempt to decrypt each wallet. Group the names by wallet to create a + // LUT of decrypted privatekeys. + const nameOwners = names.map(n => n.owner); + const decryptResults = await decryptAddresses( + masterPassword, walletAddressMap, nameOwners + ); + + // Check if there were any decryption errors + if (Object.values(decryptResults).includes(DecryptErrorGone)) + throw tErr("errorWalletGone"); + + const decryptFailed = Object.entries(decryptResults) + .find(([_, r]) => r === DecryptErrorFailed); + if (decryptFailed) { + throw new Error(t( + tKey("errorWalletDecrypt"), + { address: decryptFailed[0] } + )); + } + + // We've already validated the names, so these can be cast + const finalAddresses = decryptResults as ValidDecryptedAddresses; + const finalNames = names.map(n => ({ name: n.key, owner: n.owner })); + + if (mode === "transfer") { + // Transfer the names + await transferNames(finalAddresses, finalNames, recipient!); + } else if (mode === "update") { + // Update the names + await updateNames(finalAddresses, finalNames, aRecord!); + } + + // Success! Show notification and close modal + const count = names.length; + notif.success({ + message: t(tKey("successMessage"), { count }), + description: + }); + + setSubmitting(false); + closeModal(); + } + + // Validate the form and consolidate all the data before submission + 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 { names, recipient, aRecord } = values; + + // Lookup the names list one last time, to associate the name owners + // to the wallets for decryption, and to show the correct confirmation + // modal. + const nameGroups = await fetchNames(t, nameSuffix, walletAddressMap); + if (!nameGroups) { + // This shouldn't happen, but if the owner suddenly has no names anymore, + // show an error. + notification.error({ + message: tStr("errorNotifTitle"), + description: tStr("errorNameRequired") + }); + setSubmitting(false); + return; + } + + // Filter out names owned by the recipient, just in case. + const filteredNameGroups = nameGroups + .filter(g => g.wallet.address !== recipient); + + // The names from filteredNameGroups that we actually want to edit + const namesLUT = buildLUT(names); + const allNames = filteredNameGroups.flatMap(g => g.names); + const filteredNames = allNames.filter(n => !!namesLUT[n.key]); + + // All the names owned by the wallets, used for the confirmation modal. + const allNamesCount = allNames.length; + const count = filteredNames.length; + + // Don't return this promise, so the confirm modal closes immediately + const triggerSubmit = () => { + handleSubmit(filteredNames, recipient, aRecord) + .catch(onError) + .finally(() => setSubmitting(false)); + }; + + if (mode === "transfer" && count > 1) { + // If transferring multiple names, prompt for confirmation + showConfirmModal( + t, confirmModal, + count, allNamesCount, recipient!, + triggerSubmit, setSubmitting + ); + } else { + // Otherwise, submit straight away + triggerSubmit(); + } + } + + function onValuesChange(_: unknown, values: Partial) { + setNames(values.names || undefined); + setRecipient(values.recipient || undefined); + } + + function closeModal() { + // Don't allow closing the modal while submitting + if (submitting) return; + + setVisible(false); + form.resetFields(); + setNames(undefined); + setRecipient(undefined); + } + + const modal = +
+ {/* Names */} + form.setFieldsValue({ names })} + + multiple + allowAll + /> + + {/* Display the correct input; an address picker for transfer recipients, + * or a textbox for A records. */} + {mode === "transfer" + ? ( + // Transfer - Recipient + + ) + : ( + // Update - A record + + )} + +
; + + return <> + {modal} + + {/* Give the modals somewhere to find the context from. This is done + * outside of the modal so that they don't get immediately destroyed when + * the modal closes. */} + {contextHolder} + {authFailedContextHolder} + {notifContextHolder} + ; +} diff --git a/src/pages/names/mgmt/NameEditModalLink.tsx b/src/pages/names/mgmt/NameEditModalLink.tsx new file mode 100644 index 0000000..9e0d0b3 --- /dev/null +++ b/src/pages/names/mgmt/NameEditModalLink.tsx @@ -0,0 +1,37 @@ +// 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 { NameEditModal, Mode } from "./NameEditModal"; + +interface Props { + name?: string; + aRecord?: string | null; + mode: Mode; +} + +export const NameEditModalLink: FC = ({ + name, + aRecord, + mode, + children +}): JSX.Element => { + const [modalVisible, setModalVisible] = useState(false); + + return <> + setModalVisible(true)}> + {children} + + + + ; +}; diff --git a/src/pages/names/mgmt/NameTransferModal.tsx b/src/pages/names/mgmt/NameTransferModal.tsx deleted file mode 100644 index a3afd76..0000000 --- a/src/pages/names/mgmt/NameTransferModal.tsx +++ /dev/null @@ -1,353 +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 { useState, Dispatch, SetStateAction } from "react"; -import { Modal, Form, notification } from "antd"; - -import { useTranslation, Trans } from "react-i18next"; -import { TranslatedError, translateError } from "@utils/i18n"; - -import { - useWallets, useMasterPasswordOnly, - decryptAddresses, DecryptErrorGone, DecryptErrorFailed, - ValidDecryptedAddresses -} from "@wallets"; -import { NameOption, fetchNames } from "./lookupNames"; -import { useNameSuffix } from "@utils/currency"; - -import { APIError } from "@api"; -import { transferNames } from "@api/names"; -import { useAuthFailedModal, AuthFailedError } from "@api/AuthFailed"; - -import { NamePicker } from "./NamePicker"; -import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; -import { ContextualAddress } from "@comp/addresses/ContextualAddress"; - -import awaitTo from "await-to-js"; -import { groupBy } from "lodash-es"; - -import Debug from "debug"; -const debug = Debug("kristweb:name-transfer-modal"); - -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(); - - // Confirmation modal used for when sending multiple names. - // This is created here to provide a translation context for the modal. - const [confirmModal, contextHolder] = Modal.useModal(); - // Modal used when auth fails - const { showAuthFailed, authFailedContextHolder } = useAuthFailedModal(); - // Context for translation in the success notification - const [notif, notifContextHolder] = notification.useNotification(); - - // Used to fetch the list of available names - const { walletAddressMap } = useWallets(); - const nameSuffix = useNameSuffix(); - // Used to decrypt the wallets for transfer - const masterPassword = useMasterPasswordOnly(); - - // Actually perform the bulk name transfer - async function handleSubmit(names: NameOption[], recipient: string) { - if (!masterPassword) return; - debug("submitting with names %o", names); - - // Attempt to decrypt each wallet. Group the names by wallet to create a - // LUT of decrypted privatekeys. - const nameOwners = names.map(n => n.owner); - const decryptResults = await decryptAddresses( - masterPassword, walletAddressMap, nameOwners - ); - - // Check if there were any decryption errors - if (Object.values(decryptResults).includes(DecryptErrorGone)) - throw new TranslatedError("nameTransfer.errorWalletGone"); - - const decryptFailed = Object.entries(decryptResults) - .find(([_, r]) => r === DecryptErrorFailed); - if (decryptFailed) { - throw new Error(t( - "nameTransfer.errorWalletDecrypt", - { address: decryptFailed[0] } - )); - } - - // Finally perform the transfer - await transferNames( - // We've already validated the names - decryptResults as ValidDecryptedAddresses, - names.map(n => ({ name: n.key, owner: n.owner })), - recipient - ); - - - // Success! Show notification and close modal - const count = names.length; - - notif.success({ - message: t("nameTransfer.successMessage", { count }), - description: - }); - - setSubmitting(false); - closeModal(); - } - - // Convert API errors to friendlier errors - function handleError(err: Error) { - // Construct a TranslatedError pre-keyed to nameTransfer - const tErr = (key: string) => new TranslatedError("nameTransfer." + key); - const onError = (err: Error) => notification.error({ - message: t("nameTransfer.errorNotificationTitle"), - description: translateError(t, err, "nameTransfer.errorUnknown") - }); - - switch (err.message) { - case "missing_parameter": - case "invalid_parameter": - switch ((err as APIError).parameter) { - case "name": - return onError(tErr("errorParameterNames")); - case "address": - return onError(tErr("errorParameterRecipient")); - } - break; - case "name_not_found": - return onError(tErr("errorNameNotFound")); - case "not_name_owner": - return onError(tErr("errorNotNameOwner")); - case "auth_failed": - return showAuthFailed(walletAddressMap[(err as AuthFailedError).address!]); - } - - // Pass through any other unknown errors - console.error(err); - onError(err); - } - - // Validate the form and consolidate all the data before submission - 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; - } - - // Convert the desired names to a lookup table - const { names, recipient } = values; - const namesLUT = names - .reduce((out, name) => { - out[name] = true; - return out; - }, {} as Record); - - // Lookup the names list one last time, to associate the name owners - // to the wallets for decryption, and to show the correct confirmation - // modal. - const nameGroups = await fetchNames(t, nameSuffix, walletAddressMap); - if (!nameGroups) { - // This shouldn't happen, but if the owner suddenly has no names anymore, - // show an error. - notification.error({ - message: t("nameTransfer.errorNotifTitle"), - description: t("nameTransfer.errorNameRequired") - }); - setSubmitting(false); - return; - } - - // Filter out names owned by the recipient, just in case. - const filteredNameGroups = nameGroups - .filter(g => g.wallet.address !== recipient); - - // The names from filteredNameGroups that we actually want to transfer - const allNames = filteredNameGroups.flatMap(g => g.names); - const filteredNames = allNames.filter(n => !!namesLUT[n.key]); - - // All the names owned by the wallets, used for the confirmation modal. - const allNamesCount = allNames.length; - const count = filteredNames.length; - - // If sending multiple names, prompt for confirmation - if (count > 1) { - confirmModal.confirm({ - title: t("nameTransfer.modalTitle"), - content: , - - okText: t("nameTransfer.buttonSubmit"), - onOk: () => { - // Don't return this promise, so the dialog closes immediately - handleSubmit(filteredNames, recipient) - .catch(handleError) - .finally(() => setSubmitting(false)); - }, - - cancelText: t("dialog.cancel"), - onCancel: () => setSubmitting(false) - }); - } else { - handleSubmit(filteredNames, recipient) - .catch(handleError) - .finally(() => setSubmitting(false)); - } - } - - function onValuesChange(_: unknown, values: Partial) { - setNames(values.names || undefined); - setRecipient(values.recipient || undefined); - } - - function closeModal() { - // Don't allow closing the modal while submitting - if (submitting) return; - - setVisible(false); - form.resetFields(); - setNames(undefined); - setRecipient(undefined); - } - - const modal = -
- {/* Names */} - form.setFieldsValue({ names })} - - multiple - allowAll - /> - - {/* Recipient */} - - -
; - - return <> - {modal} - - {/* Give the modals somewhere to find the context from. This is done - * outside of the modal so that they don't get immediately destroyed when - * the modal closes. */} - {contextHolder} - {authFailedContextHolder} - {notifContextHolder} - ; -} - -interface CountRecipient { - count: number; - recipient: string; -} - -function ConfirmModalContent({ - count, - recipient, - allNamesCount -}: CountRecipient & { allNamesCount: number }): JSX.Element { - const { t } = useTranslation(); - - // Show the appropriate message, if this is all the owner's names - return = allNamesCount - ? "nameTransfer.warningAllNames" - : "nameTransfer.warningMultipleNames"} - count={count} - > - Are you sure you want to transfer {{ count }} names to - ? - ; -} - -function SuccessNotifContent({ - count, - recipient -}: CountRecipient): JSX.Element { - const { t } = useTranslation(); - - // Show the appropriate message, if this is all the owner's names - return - Transferred {{ count }} names to - . - ; -} diff --git a/src/pages/names/mgmt/SuccessNotifContent.tsx b/src/pages/names/mgmt/SuccessNotifContent.tsx new file mode 100644 index 0000000..1cdb246 --- /dev/null +++ b/src/pages/names/mgmt/SuccessNotifContent.tsx @@ -0,0 +1,46 @@ +// 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 { ContextualAddress } from "@comp/addresses/ContextualAddress"; + +import { Mode } from "./NameEditModal"; + +interface Props { + count: number; + recipient?: string; + mode: Mode; +} + +export function SuccessNotifContent({ + count, + recipient, + mode +}: Props): JSX.Element | null { + const { t } = useTranslation(); + + // Show the appropriate message, if this is all the owner's names + if (mode === "transfer") { + // Transfer names success notification + return + Transferred {{ count }} names to + . + ; + } else if (mode === "update") { + // Update names success notification + return + Updated {{ count }} names. + ; + } else { + return null; + } +} diff --git a/src/pages/names/mgmt/handleErrors.ts b/src/pages/names/mgmt/handleErrors.ts new file mode 100644 index 0000000..a988c79 --- /dev/null +++ b/src/pages/names/mgmt/handleErrors.ts @@ -0,0 +1,48 @@ +// 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 { notification } from "antd"; + +import { translateError, TFns } from "@utils/i18n"; + +import { APIError } from "@api"; +import { AuthFailedError, ShowAuthFailedFn } from "@api/AuthFailed"; + +import { WalletAddressMap } from "@wallets"; + +// Convert API errors to friendlier errors +export async function handleError( + { t, tKey, tStr, tErr }: TFns, + showAuthFailed: ShowAuthFailedFn, + walletAddressMap: WalletAddressMap, + 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": + switch ((err as APIError).parameter) { + case "name": + return onError(tErr("errorParameterNames")); + case "address": + return onError(tErr("errorParameterRecipient")); + case "a": + return onError(tErr("errorParameterARecord")); + } + break; + case "name_not_found": + return onError(tErr("errorNameNotFound")); + case "not_name_owner": + return onError(tErr("errorNotNameOwner")); + case "auth_failed": + return showAuthFailed(walletAddressMap[(err as AuthFailedError).address!]); + } + + // Pass through any other unknown errors + console.error(err); + onError(err); +} diff --git a/src/pages/names/mgmt/lookupNames.ts b/src/pages/names/mgmt/lookupNames.ts index cb5e5ee..1971433 100644 --- a/src/pages/names/mgmt/lookupNames.ts +++ b/src/pages/names/mgmt/lookupNames.ts @@ -83,3 +83,9 @@ return null; } } + +export const buildLUT = (names: string[]): Record => + names.reduce((out, name) => { + out[name] = true; + return out; + }, {} as Record); diff --git a/src/pages/wallets/WalletActions.tsx b/src/pages/wallets/WalletActions.tsx index 20b79a7..feea5a9 100644 --- a/src/pages/wallets/WalletActions.tsx +++ b/src/pages/wallets/WalletActions.tsx @@ -51,7 +51,7 @@ return <> [ diff --git a/src/pages/wallets/WalletsPage.less b/src/pages/wallets/WalletsPage.less deleted file mode 100644 index 41dfca8..0000000 --- a/src/pages/wallets/WalletsPage.less +++ /dev/null @@ -1,16 +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"; - -.wallet-actions .ant-btn { - &:not(:first-child) { - margin-left: 1px; - } - - &:first-child { - padding-left: 0; - padding-right: 0; - width: 40px; - } -} diff --git a/src/pages/wallets/WalletsPage.tsx b/src/pages/wallets/WalletsPage.tsx index f02ddc9..6a171aa 100644 --- a/src/pages/wallets/WalletsPage.tsx +++ b/src/pages/wallets/WalletsPage.tsx @@ -14,8 +14,6 @@ import { useWallets } from "@wallets"; -import "./WalletsPage.less"; - /** Extract the subtitle into its own component to avoid re-rendering the * entire page when a wallet is added. */ function WalletsPageSubtitle(): JSX.Element { diff --git a/src/style/components.less b/src/style/components.less index 10af05d..ac7fa9d 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -223,3 +223,19 @@ font-size: @font-size-sm; vertical-align: -0.1em; } + +.table-actions { + float: right; + + .ant-btn { + &:not(:first-child) { + margin-left: 1px; + } + + &:first-child { + padding-left: 0; + padding-right: 0; + width: 40px; + } + } +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts deleted file mode 100644 index 04dd4b0..0000000 --- a/src/utils/i18n.ts +++ /dev/null @@ -1,104 +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 { notification } from "antd"; - -import { isLocalhost } from "./"; - -import i18n from "i18next"; -import Backend from "i18next-http-backend"; -import LanguageDetector from "i18next-browser-languagedetector"; -import { initReactI18next, TFunction } from "react-i18next"; -import JSON5 from "json5"; - -import languagesJson from "../__data__/languages.json"; - -// Replaced by webpack DefinePlugin and git-revision-webpack-plugin -declare const __GIT_VERSION__: string; -const gitVersion: string = __GIT_VERSION__; - -export interface Language { - name: string; - nativeName?: string; - country?: string; - dayjsLocale?: string; - timeagoLocale?: string; - antLocale?: string; - contributors: Contributor[]; -} - -export interface Contributor { - name: string; - url?: string; -} - -export type Languages = { [key: string]: Language } | null; -export function getLanguages(): Languages { - return languagesJson; -} - -export class TranslatedError extends Error { - constructor(message: string) { super(message); } -} - -export function translateError(t: TFunction, error: Error, unknownErrorKey?: string): string { - if (error instanceof TranslatedError) { - return t(error.message); - } else { - return unknownErrorKey ? t(unknownErrorKey) : error.message; - } -} - -// Provided as a testing polyfill -/*const DEFAULT_LANGUAGE = { "en": { - "name": "English (GB)", - "country": "GB", - "contributors": [] -}};*/ - -export const i18nLoader = i18n - .use(Backend) - .use(LanguageDetector) - .use(initReactI18next) - .init({ - fallbackLng: "en", - supportedLngs: [...Object.keys(getLanguages() || { "en": {} }), "und"], - - debug: isLocalhost, - - keySeparator: ".", - - interpolation: { - escapeValue: false, // React already safes from XSS - - format(value, format) { - // Format numbers with commas - if (format === "number" && typeof value === "number") - return value.toLocaleString(); - - return value; - } - }, - - backend: { - queryStringParams: { v: encodeURIComponent(gitVersion) }, - loadPath: "/locales/{{lng}}.json", - - // Translations now use JSON5 to allow for comments, newlines, and basic - // syntax errors like trailing commas - parse: JSON5.parse - } - }) - .then(() => { - // If the language was set to a custom debug language, reset it - if (i18n.language === "und") { - i18n.changeLanguage("en"); - // Intentionally untranslated - notification.info({ - message: "Language reverted to English.", - description: "You were previously using a custom debug translation." - }); - } - }); - -export default i18n; diff --git a/src/utils/i18n/errors.ts b/src/utils/i18n/errors.ts new file mode 100644 index 0000000..6dbde32 --- /dev/null +++ b/src/utils/i18n/errors.ts @@ -0,0 +1,16 @@ +// 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"; + +export class TranslatedError extends Error { + constructor(message: string) { super(message); } +} + +export function translateError(t: TFunction, error: Error, unknownErrorKey?: string): string { + if (error instanceof TranslatedError) { + return t(error.message); + } else { + return unknownErrorKey ? t(unknownErrorKey) : error.message; + } +} diff --git a/src/utils/i18n/fns.ts b/src/utils/i18n/fns.ts new file mode 100644 index 0000000..2236e60 --- /dev/null +++ b/src/utils/i18n/fns.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 { useTranslation, TFunction } from "react-i18next"; +import { TranslatedError } from "./errors"; + +export type TKeyFn = (key: string) => string; +export type TStrFn = (key: string) => string; +export type TErrFn = (key: string) => TranslatedError; +export interface TFns { + t: TFunction; + tKey: TKeyFn; + tStr: TStrFn; + tErr: TErrFn; +} +export function useTranslationFns(prefix?: string): TFns { + const { t } = useTranslation(); + const tKey = (key: string) => prefix + key; + const tStr = (key: string) => t(tKey(key)); + const tErr = (key: string) => new TranslatedError(tKey(key)); + + return { t, tKey, tStr, tErr }; +} +export const useTFns = useTranslationFns; diff --git a/src/utils/i18n/index.ts b/src/utils/i18n/index.ts new file mode 100644 index 0000000..91f4f8a --- /dev/null +++ b/src/utils/i18n/index.ts @@ -0,0 +1,10 @@ +// 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 i18n from "./init"; +export * from "./init"; +export * from "./languages"; +export * from "./fns"; +export * from "./errors"; + +export default i18n; diff --git a/src/utils/i18n/init.ts b/src/utils/i18n/init.ts new file mode 100644 index 0000000..922affd --- /dev/null +++ b/src/utils/i18n/init.ts @@ -0,0 +1,66 @@ +// 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 { notification } from "antd"; + +import i18n from "i18next"; +import Backend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; + +import { initReactI18next } from "react-i18next"; + +import JSON5 from "json5"; + +import { isLocalhost } from ".."; +import { getLanguages } from "./languages"; + +// Replaced by webpack DefinePlugin and git-revision-webpack-plugin +declare const __GIT_VERSION__: string; +const gitVersion: string = __GIT_VERSION__; + +export const i18nLoader = i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: "en", + supportedLngs: [...Object.keys(getLanguages() || { "en": {} }), "und"], + + debug: isLocalhost, + + keySeparator: ".", + + interpolation: { + escapeValue: false, // React already safes from XSS + + format(value, format) { + // Format numbers with commas + if (format === "number" && typeof value === "number") + return value.toLocaleString(); + + return value; + } + }, + + backend: { + queryStringParams: { v: encodeURIComponent(gitVersion) }, + loadPath: "/locales/{{lng}}.json", + + // Translations now use JSON5 to allow for comments, newlines, and basic + // syntax errors like trailing commas + parse: JSON5.parse + } + }) + .then(() => { + // If the language was set to a custom debug language, reset it + if (i18n.language === "und") { + i18n.changeLanguage("en"); + // Intentionally untranslated + notification.info({ + message: "Language reverted to English.", + description: "You were previously using a custom debug translation." + }); + } + }); + +export default i18n; diff --git a/src/utils/i18n/languages.ts b/src/utils/i18n/languages.ts new file mode 100644 index 0000000..8b29bec --- /dev/null +++ b/src/utils/i18n/languages.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 languagesJson from "../../__data__/languages.json"; + +export interface Language { + name: string; + nativeName?: string; + country?: string; + dayjsLocale?: string; + timeagoLocale?: string; + antLocale?: string; + contributors: Contributor[]; +} + +export interface Contributor { + name: string; + url?: string; +} + +export type Languages = { [key: string]: Language } | null; +export function getLanguages(): Languages { + return languagesJson; +}