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 : (
)}
{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 (
+
+ )
+ })
+)
+
+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 : (
+
+ )}
+ {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) {