diff --git a/i18n/locales/en/generic.json b/i18n/locales/en/generic.json index 4f620dd61..027677a96 100644 --- a/i18n/locales/en/generic.json +++ b/i18n/locales/en/generic.json @@ -1,4 +1,8 @@ { + "asset-selector": { + "placeholder": "Select an asset", + "select": "Select" + }, "dialog-actions": { "close": { "label": "Close" @@ -78,6 +82,10 @@ "cancel": "Cancel" } }, + "memo-selector": { + "placeholder": "Select a memo", + "select": "Select" + }, "submission-progress": { "pending": "Submitting to network ...", "success": { diff --git a/i18n/locales/en/payment.json b/i18n/locales/en/payment.json index cffbdba23..90329f0dc 100644 --- a/i18n/locales/en/payment.json +++ b/i18n/locales/en/payment.json @@ -5,8 +5,7 @@ "memo-metadata": { "label": { "default": "Memo", - "id": "Memo (ID)", - "text": "Memo (Text)" + "required": "Memo (Required)" }, "placeholder": { "optional": "Description (optional)", diff --git a/src/Assets/components/AddAssetDialog.tsx b/src/Assets/components/AddAssetDialog.tsx index 99ed59b07..b55817d4d 100644 --- a/src/Assets/components/AddAssetDialog.tsx +++ b/src/Assets/components/AddAssetDialog.tsx @@ -242,7 +242,7 @@ const AddAssetDialog = React.memo(function AddAssetDialog(props: AddAssetDialogP const allAssets = useTickerAssets(props.account.testnet) const router = useRouter() const { t } = useTranslation() - const wellKnownAccounts = useWellKnownAccounts(props.account.testnet) + const wellKnownAccounts = useWellKnownAccounts() const [customTrustlineDialogOpen, setCustomTrustlineDialogOpen] = React.useState(false) const [searchFieldValue, setSearchFieldValue] = React.useState("") const [txCreationPending, setTxCreationPending] = React.useState(false) @@ -283,15 +283,15 @@ const AddAssetDialog = React.memo(function AddAssetDialog(props: AddAssetDialogP } const wellknownAccountMatches = React.useCallback( - (accountID: string, search: string) => { + async (accountID: string, search: string) => { const lowerCasedSearch = search.toLowerCase() - const record = wellKnownAccounts.lookup(accountID) + const record = await wellKnownAccounts.lookup(accountID) if (!record) { return false } return ( - record.domain.toLowerCase().includes(lowerCasedSearch) || record.name.toLowerCase().includes(lowerCasedSearch) + record.domain?.toLowerCase().includes(lowerCasedSearch) || record.name.toLowerCase().includes(lowerCasedSearch) ) }, [wellKnownAccounts] diff --git a/src/Generic/components/AssetSelector.tsx b/src/Generic/components/AssetSelector.tsx index d7e534b45..5bca2b479 100644 --- a/src/Generic/components/AssetSelector.tsx +++ b/src/Generic/components/AssetSelector.tsx @@ -1,4 +1,5 @@ import React from "react" +import { useTranslation } from "react-i18next" import { Asset, Horizon } from "stellar-sdk" import ListItemIcon from "@material-ui/core/ListItemIcon" import ListItemText from "@material-ui/core/ListItemText" @@ -90,6 +91,7 @@ interface AssetSelectorProps { function AssetSelector(props: AssetSelectorProps) { const { onChange } = props const classes = useAssetSelectorStyles() + const { t } = useTranslation() const assets = React.useMemo( () => [ @@ -129,7 +131,7 @@ function AssetSelector(props: AssetSelectorProps) { margin={props.margin} onChange={handleChange as any} name={props.name} - placeholder="Select an asset" + placeholder={t("generic.asset-selector.placeholder")} select style={{ flexShrink: 0, ...props.style }} value={props.value ? props.value.getCode() : ""} @@ -151,12 +153,12 @@ function AssetSelector(props: AssetSelectorProps) { }, displayEmpty: !props.value, disableUnderline: props.disableUnderline, - renderValue: () => (props.value ? props.value.getCode() : "Select") + renderValue: () => (props.value ? props.value.getCode() : t("generic.asset-selector.select")) }} > {props.value ? null : ( - Select an asset + {t("generic.asset-selector.placeholder")} )} {props.showXLM ? ( diff --git a/src/Generic/components/Fetchers.tsx b/src/Generic/components/Fetchers.tsx index 5ddb0ee51..f3a43ee92 100644 --- a/src/Generic/components/Fetchers.tsx +++ b/src/Generic/components/Fetchers.tsx @@ -1,6 +1,6 @@ import React from "react" import { useAccountHomeDomainSafe } from "../hooks/stellar" -import { useWellKnownAccounts } from "../hooks/stellar-ecosystem" +import { AccountRecord, useWellKnownAccounts } from "../hooks/stellar-ecosystem" import { Address } from "./PublicKey" interface AccountNameProps { @@ -9,9 +9,13 @@ interface AccountNameProps { } export const AccountName = React.memo(function AccountName(props: AccountNameProps) { - const wellknownAccounts = useWellKnownAccounts(props.testnet) + const wellknownAccounts = useWellKnownAccounts() const homeDomain = useAccountHomeDomainSafe(props.publicKey, props.testnet, true) - const record = wellknownAccounts.lookup(props.publicKey) + const [record, setRecord] = React.useState(undefined) + + React.useEffect(() => { + wellknownAccounts.lookup(props.publicKey).then(setRecord) + }, [props.publicKey, wellknownAccounts]) if (record && record.domain) { return {record.domain} diff --git a/src/Generic/components/FormFields.tsx b/src/Generic/components/FormFields.tsx index 930833ce3..c5f5b5715 100644 --- a/src/Generic/components/FormFields.tsx +++ b/src/Generic/components/FormFields.tsx @@ -87,6 +87,41 @@ export const PriceInput = React.memo(function PriceInput(props: PriceInputProps) ) }) +type MemoInputProps = TextFieldProps & { + memoSelector: React.ReactNode + memoStyle?: React.CSSProperties + readOnly?: boolean +} + +export const MemoInput = React.memo(function MemoInput(props: MemoInputProps) { + const { memoSelector, memoStyle, readOnly, ...textfieldProps } = props + const InputField = readOnly ? ReadOnlyTextfield : TextField + return ( + + {memoSelector} + + ), + ...textfieldProps.InputProps + }} + style={{ + pointerEvents: props.readOnly ? "none" : undefined, + ...textfieldProps.style + }} + /> + ) +}) + const useReadOnlyTextfieldStyles = makeStyles({ root: { "&:focus": { diff --git a/src/Generic/components/MemoSelector.tsx b/src/Generic/components/MemoSelector.tsx new file mode 100644 index 000000000..ccf3382d8 --- /dev/null +++ b/src/Generic/components/MemoSelector.tsx @@ -0,0 +1,142 @@ +import React from "react" +import { useTranslation } from "react-i18next" +import { MemoHash, MemoID, MemoNone, MemoReturn, MemoText, MemoType } from "stellar-sdk" +import ListItemText from "@material-ui/core/ListItemText" +import MenuItem from "@material-ui/core/MenuItem" +import { makeStyles } from "@material-ui/core/styles" +import TextField, { TextFieldProps } from "@material-ui/core/TextField" + +interface MemoItemProps { + disabled?: boolean + key: string + memoType: MemoType + value: string +} + +function capitalizeFirstLetter(word: string) { + return word.charAt(0).toUpperCase() + word.slice(1) +} + +const MemoItem = React.memo( + React.forwardRef(function MemoItem(props: MemoItemProps, ref: React.Ref) { + const { memoType, ...reducedProps } = props + + return ( + + {capitalizeFirstLetter(memoType)} + + ) + }) +) + +const useMemoSelectorStyles = makeStyles({ + helperText: { + maxWidth: 100, + whiteSpace: "nowrap" + }, + input: { + minWidth: 72 + }, + select: { + fontSize: 18, + fontWeight: 400 + }, + unselected: { + opacity: 0.5 + } +}) + +interface MemoSelectorProps { + autoFocus?: TextFieldProps["autoFocus"] + children?: React.ReactNode + className?: string + disabledMemos?: MemoType[] + disableUnderline?: boolean + helperText?: TextFieldProps["helperText"] + inputError?: string + label?: TextFieldProps["label"] + margin?: TextFieldProps["margin"] + minWidth?: number | string + name?: string + onChange?: (memoType: MemoType) => void + style?: React.CSSProperties + value?: MemoType +} + +function MemoSelector(props: MemoSelectorProps) { + const { onChange } = props + const classes = useMemoSelectorStyles() + + const { t } = useTranslation() + + const memoTypes = React.useMemo(() => [MemoNone, MemoText, MemoID, MemoHash, MemoReturn], []) + + const handleChange = React.useCallback( + (event: React.ChangeEvent<{ name?: any; value: any }>, child: React.ComponentElement) => { + const matchingMemo = memoTypes.find(memoType => memoType === child.props.memoType) + + if (matchingMemo) { + if (onChange) { + onChange(matchingMemo) + } + } else { + // tslint:disable-next-line no-console + console.error(`Invariant violation: Trustline ${child.props.memoType} selected, but no matching memo found.`) + } + }, + [memoTypes, onChange] + ) + + return ( + (props.value ? capitalizeFirstLetter(props.value) : t("generic.memo-selector.select")) + }} + > + {props.value ? null : ( + + {t("generic.memo-selector.placeholder")} + + )} + {memoTypes.map(memoType => ( + someMemo === memoType)} + key={memoType} + value={memoType} + /> + ))} + + ) +} + +export default React.memo(MemoSelector) diff --git a/src/Generic/hooks/stellar-ecosystem.ts b/src/Generic/hooks/stellar-ecosystem.ts index 0ed4aae85..7c6edde1d 100644 --- a/src/Generic/hooks/stellar-ecosystem.ts +++ b/src/Generic/hooks/stellar-ecosystem.ts @@ -1,9 +1,7 @@ import React from "react" -import { trackError } from "~App/contexts/notifications" -import { AccountRecord, fetchWellknownAccounts } from "../lib/stellar-expert" +import { AccountRecord, fetchWellKnownAccount } from "../lib/stellar-expert" import { AssetRecord, fetchAllAssets } from "../lib/stellar-ticker" -import { tickerAssetsCache, wellKnownAccountsCache } from "./_caches" -import { useForceRerender } from "./util" +import { tickerAssetsCache } from "./_caches" export { AccountRecord, AssetRecord } @@ -12,33 +10,14 @@ export function useTickerAssets(testnet: boolean) { return tickerAssetsCache.get(testnet) || tickerAssetsCache.suspend(testnet, fetchAssets) } -export function useWellKnownAccounts(testnet: boolean) { - let accounts: AccountRecord[] - - const forceRerender = useForceRerender() - const fetchAccounts = () => fetchWellknownAccounts(testnet) - - try { - accounts = wellKnownAccountsCache.get(testnet) || wellKnownAccountsCache.suspend(testnet, fetchAccounts) - } catch (thrown) { - if (thrown && typeof thrown.then === "function") { - // Promise thrown to suspend component – prevent suspension - accounts = [] - thrown.then(forceRerender, trackError) - } else { - // It's an error – re-throw - throw thrown - } - } - +export function useWellKnownAccounts() { const wellknownAccounts = React.useMemo(() => { return { - accounts, - lookup(publicKey: string): AccountRecord | undefined { - return accounts.find(account => account.address === publicKey) + lookup(publicKey: string): Promise { + return fetchWellKnownAccount(publicKey) } } - }, [accounts]) + }, []) return wellknownAccounts } diff --git a/src/Generic/lib/stellar-expert.ts b/src/Generic/lib/stellar-expert.ts index 4ad981ae2..56f64adc9 100644 --- a/src/Generic/lib/stellar-expert.ts +++ b/src/Generic/lib/stellar-expert.ts @@ -6,26 +6,32 @@ export interface AccountRecord { paging_token: string name: string tags: string[] - domain: string - accepts?: { - memo: "MEMO_TEXT" | "MEMO_ID" - } + domain?: string } const wellKnownAccountsCache = createPersistentCache("known-accounts", { expiresIn: 24 * 60 * 60_000 }) -export async function fetchWellknownAccounts(testnet: boolean): Promise { - const cacheKey = testnet ? "testnet" : "pubnet" +export async function fetchWellKnownAccount(accountID: string): Promise { + const cacheKey = "all" const cachedAccounts = wellKnownAccountsCache.read(cacheKey) const { netWorker } = await workers - if (cachedAccounts) { - return cachedAccounts + const cachedAccount = cachedAccounts && cachedAccounts.find(account => account.address === accountID) + + if (cachedAccount) { + return cachedAccount } else { - const knownAccounts = await netWorker.fetchWellknownAccounts(testnet) + const fetchedAccount = await netWorker.fetchWellknownAccount(accountID) + + if (fetchedAccount) { + const newKnownAccounts: AccountRecord[] = cachedAccounts + ? cachedAccounts.concat(fetchedAccount) + : [fetchedAccount] + + wellKnownAccountsCache.save(cacheKey, newKnownAccounts) + } - wellKnownAccountsCache.save(cacheKey, knownAccounts) - return knownAccounts + return fetchedAccount || undefined } } diff --git a/src/Payment/components/PaymentForm.tsx b/src/Payment/components/PaymentForm.tsx index d9ca3fc23..2b28a9764 100644 --- a/src/Payment/components/PaymentForm.tsx +++ b/src/Payment/components/PaymentForm.tsx @@ -18,15 +18,17 @@ import { isPublicKey, isStellarAddress } from "~Generic/lib/stellar-address" import { createPaymentOperation, createTransaction, multisigMinimumFee } from "~Generic/lib/transaction" import { ActionButton, DialogActionsBox } from "~Generic/components/DialogActions" import AssetSelector from "~Generic/components/AssetSelector" -import { PriceInput, QRReader } from "~Generic/components/FormFields" +import { MemoInput, PriceInput, QRReader } from "~Generic/components/FormFields" import { formatBalance } from "~Generic/lib/balances" -import { HorizontalLayout } from "~Layout/components/Box" +import MemoSelector from "~Generic/components/MemoSelector" import Portal from "~Generic/components/Portal" +import { HorizontalLayout } from "~Layout/components/Box" export interface PaymentFormValues { amount: string asset: Asset destination: string + memoType: MemoType memoValue: string } @@ -35,7 +37,7 @@ type ExtendedPaymentFormValues = PaymentFormValues & { memoType: MemoType } interface MemoMetadata { label: string placeholder: string - requiredType: MemoType | undefined + required: boolean } function createMemo(memoType: MemoType, memoValue: string) { @@ -67,20 +69,20 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { const isSmallScreen = useIsMobile() const formID = React.useMemo(() => nanoid(), []) const { t } = useTranslation() - const wellknownAccounts = useWellKnownAccounts(props.testnet) + const wellknownAccounts = useWellKnownAccounts() const [matchingWellknownAccount, setMatchingWellknownAccount] = React.useState(undefined) - const [memoType, setMemoType] = React.useState("none") const [memoMetadata, setMemoMetadata] = React.useState({ label: t("payment.memo-metadata.label.default"), placeholder: t("payment.memo-metadata.placeholder.optional"), - requiredType: undefined + required: false }) const form = useForm({ defaultValues: { amount: "", asset: Asset.native(), destination: "", + memoType: "none", memoValue: "" } }) @@ -94,38 +96,31 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { ) React.useEffect(() => { - if (!isPublicKey(formValues.destination) && !isStellarAddress(formValues.destination)) { - if (matchingWellknownAccount) { - setMatchingWellknownAccount(undefined) - } - return + if (isPublicKey(formValues.destination) || isStellarAddress(formValues.destination)) { + wellknownAccounts.lookup(formValues.destination).then(setMatchingWellknownAccount) + } else { + setMatchingWellknownAccount(undefined) } + }, [formValues.destination, wellknownAccounts]) - const knownAccount = wellknownAccounts.lookup(formValues.destination) - setMatchingWellknownAccount(knownAccount) - - if (knownAccount && knownAccount.tags.indexOf("exchange") !== -1) { - const acceptedMemoType = knownAccount.accepts && knownAccount.accepts.memo - const requiredType = acceptedMemoType === "MEMO_ID" ? "id" : "text" - setMemoType(requiredType) + React.useEffect(() => { + if (matchingWellknownAccount && matchingWellknownAccount.tags.indexOf("memo-required") !== -1) { setMemoMetadata({ - label: - acceptedMemoType === "MEMO_ID" ? t("payment.memo-metadata.label.id") : t("payment.memo-metadata.label.text"), + label: t("payment.memo-metadata.label.required"), placeholder: t("payment.memo-metadata.placeholder.mandatory"), - requiredType + required: true }) } else { - setMemoType(formValues.memoValue.length === 0 ? "none" : "text") setMemoMetadata({ label: t("payment.memo-metadata.label.default"), placeholder: t("payment.memo-metadata.placeholder.optional"), - requiredType: undefined + required: false }) } - }, [formValues.destination, formValues.memoValue, matchingWellknownAccount, memoType, t, wellknownAccounts]) + }, [formValues.destination, formValues.memoValue, matchingWellknownAccount, t, wellknownAccounts]) const handleFormSubmission = () => { - props.onSubmit({ memoType, ...form.getValues() }, spendableBalance, matchingWellknownAccount) + props.onSubmit(form.getValues(), spendableBalance, matchingWellknownAccount) } const handleQRScan = React.useCallback( @@ -141,7 +136,7 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { const memoValue = searchParams.get("dt") if (memoValue) { - setMemoType("id") + setValue("memoType", "id") setValue("memoValue", memoValue) } }, @@ -232,9 +227,21 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { [assetSelector, form, isSmallScreen, spendableBalance, t] ) + const memoSelector = React.useMemo( + () => ( + } + control={form.control} + name="memoType" + /> + ), + [form.control, formValues.memoType] + ) + const memoInput = React.useMemo( () => ( - value.length <= 28 || t("payment.validation.memo-too-long"), memoRequired: value => - !memoMetadata.requiredType || + !memoMetadata.required || !matchingWellknownAccount || value.length > 0 || t( @@ -255,16 +262,16 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { } ), idPattern: value => - memoType !== "id" || value.match(/^[0-9]+$/) || t("payment.validation.integer-memo-required") + formValues.memoType !== "id" || + value.match(/^[0-9]+$/) || + t("payment.validation.integer-memo-required") } })} onChange={event => { const { value } = event.target - if (!memoMetadata.requiredType) { - // only change memo type if no type is required - const newMemoType = value.length === 0 ? "none" : "text" - setMemoType(newMemoType) - } + const newMemoType = + value.length === 0 ? "none" : formValues.memoType === "none" ? "text" : formValues.memoType + setValue("memoType", newMemoType) setValue("memoValue", value) }} placeholder={memoMetadata.placeholder} @@ -278,11 +285,12 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { ), [ form, - memoType, + formValues.memoType, matchingWellknownAccount, + memoSelector, memoMetadata.label, memoMetadata.placeholder, - memoMetadata.requiredType, + memoMetadata.required, setValue, t ] diff --git a/src/Workers/net-worker/stellar-ecosystem.ts b/src/Workers/net-worker/stellar-ecosystem.ts index 083686836..308538fff 100644 --- a/src/Workers/net-worker/stellar-ecosystem.ts +++ b/src/Workers/net-worker/stellar-ecosystem.ts @@ -3,10 +3,8 @@ import { AccountRecord } from "~Generic/lib/stellar-expert" import { AssetRecord } from "~Generic/lib/stellar-ticker" import { CustomError } from "~Generic/lib/errors" -export async function fetchWellknownAccounts(testnet: boolean): Promise { - const requestURL = testnet - ? "https://api.stellar.expert/api/explorer/testnet/directory" - : "https://api.stellar.expert/api/explorer/public/directory" +export async function fetchWellknownAccount(accountID: string): Promise { + const requestURL = "https://api.stellar.expert/api/explorer/directory" + `?address[]=${accountID}` const response = await fetch(requestURL) @@ -19,7 +17,8 @@ export async function fetchWellknownAccounts(testnet: boolean): Promise 0 ? knownAccounts[0] : null + return account } function byAccountCountSorter(a: AssetRecord, b: AssetRecord) {