diff --git a/web/cashtab/src/components/Common/EnhancedInputs.js b/web/cashtab/src/components/Common/EnhancedInputs.js --- a/web/cashtab/src/components/Common/EnhancedInputs.js +++ b/web/cashtab/src/components/Common/EnhancedInputs.js @@ -115,6 +115,7 @@ onMax, inputProps, selectProps, + activeFiatCode, ...otherProps }) => { const { Option } = Select; @@ -123,7 +124,10 @@ value: currency.ticker, label: currency.ticker, }, - { value: 'USD', label: 'USD' }, + { + value: activeFiatCode ? activeFiatCode : 'USD', + label: activeFiatCode ? activeFiatCode : 'USD', + }, ]; const currencyOptions = currencies.map(currency => { return ( @@ -246,6 +250,46 @@ ); }; +export const CurrencySelectDropdown = selectProps => { + const { Option } = Select; + + // Build select dropdown from currency.fiatCurrencies + const currencyMenuOptions = []; + const currencyKeys = Object.keys(currency.fiatCurrencies); + for (let i = 0; i < currencyKeys.length; i += 1) { + const currencyMenuOption = {}; + currencyMenuOption.value = + currency.fiatCurrencies[currencyKeys[i]].slug; + currencyMenuOption.label = `${ + currency.fiatCurrencies[currencyKeys[i]].name + } (${currency.fiatCurrencies[currencyKeys[i]].symbol})`; + currencyMenuOptions.push(currencyMenuOption); + } + const currencyOptions = currencyMenuOptions.map(currencyMenuOption => { + return ( + + ); + }); + + return ( + + ); +}; + export const AddressValidators = () => { const { BCH } = useBCH(); diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -24,6 +24,14 @@ useBlockchainWs: false, txHistoryCount: 5, hydrateUtxoBatchSize: 20, + defaultSettings: { fiatCurrency: 'usd' }, + settingsValidation: { fiatCurrency: ['usd', 'idr', 'krw', 'cny'] }, + fiatCurrencies: { + usd: { name: 'US Dollar', symbol: '$', slug: 'usd' }, + idr: { name: 'Indonesian Rupiah', symbol: 'Rp', slug: 'idr' }, + krw: { name: 'Korean Won', symbol: '₩', slug: 'krw' }, + cny: { name: 'Chinese Yuan', symbol: '元', slug: 'cny' }, + }, }; export function isValidCashPrefix(addressString) { diff --git a/web/cashtab/src/components/Configure/Configure.js b/web/cashtab/src/components/Configure/Configure.js --- a/web/cashtab/src/components/Configure/Configure.js +++ b/web/cashtab/src/components/Configure/Configure.js @@ -10,7 +10,10 @@ } from '@ant-design/icons'; import { WalletContext } from '@utils/context'; import { StyledCollapse } from '@components/Common/StyledCollapse'; -import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; +import { + AntdFormWrapper, + CurrencySelectDropdown, +} from '@components/Common/EnhancedInputs'; import PrimaryButton, { SecondaryButton, SmartButton, @@ -20,6 +23,7 @@ CashLoadingIcon, ThemedCopyOutlined, ThemedWalletOutlined, + ThemedDollarOutlined, } from '@components/Common/CustomIcons'; import { ReactComponent as Trashcan } from '@assets/trashcan.svg'; import { ReactComponent as Edit } from '@assets/edit.svg'; @@ -172,6 +176,8 @@ deleteWallet, validateMnemonic, getSavedWallets, + cashtabSettings, + changeCashtabSettings, } = ContextValue; const [savedWallets, setSavedWallets] = useState([]); const [formData, setFormData] = useState({ @@ -582,6 +588,22 @@ )} + +

+ Fiat Currency +

+ + + changeCashtabSettings('fiatCurrency', fiatCode) + } + /> + [ +

+ + + + Fiat Currency +

+
+
+
+ + + + + US Dollar ($) + +
+ + + + + +
+
+
[ +

+ + + + Fiat Currency +

+
+
+
+ + + + + US Dollar ($) + +
+ + + + + +
+
+
[ @@ -404,8 +419,19 @@ {fiatPrice !== null && ( - ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} - USD + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].symbol + } ` + : '$ '} + {(balances.totalBalance * fiatPrice).toFixed(2)}{' '} + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} )} @@ -448,6 +474,12 @@ }} > - $ + $ NaN USD @@ -333,7 +333,7 @@ > = - $ NaN USD + $ NaN USD
- $ + $ NaN USD @@ -690,7 +690,7 @@ > = - $ NaN USD + $ NaN USD
- $ + $ NaN USD @@ -1047,7 +1047,7 @@ > = - $ NaN USD + $ NaN USD
- $ + $ NaN USD @@ -1404,7 +1404,7 @@ > = - $ NaN USD + $ NaN USD
= - $ NaN USD + $ NaN USD
= - $ NaN USD + $ NaN USD
- $ + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings + .fiatCurrency + ].symbol + } ` + : '$ '} {( balances.totalBalance * fiatPrice ).toFixed(2)}{' '} - USD + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} )} diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -141,7 +141,7 @@ } `; -const Tx = ({ data, fiatPrice }) => { +const Tx = ({ data, fiatPrice, fiatCurrency }) => { const txDate = typeof data.blocktime === 'undefined' ? new Date().toLocaleDateString() @@ -270,18 +270,22 @@ )} {currency.ticker}
- {fiatPrice !== null && - !isNaN(data.amountSent) && ( - - - $ - {( - fromLegacyDecimals( - data.amountSent, - ) * fiatPrice - ).toFixed(2)}{' '} - USD - - )} + {fiatPrice !== null && !isNaN(data.amountSent) && ( + + -{' '} + { + currency.fiatCurrencies[ + fiatCurrency + ].symbol + } + {( + fromLegacyDecimals( + data.amountSent, + ) * fiatPrice + ).toFixed(2)}{' '} + {currency.fiatCurrencies.fiatCurrency} + + )} ) : ( <> @@ -294,13 +298,21 @@ {fiatPrice !== null && !isNaN(data.amountReceived) && ( - + $ + +{' '} + { + currency.fiatCurrencies[ + fiatCurrency + ].symbol + } {( fromLegacyDecimals( data.amountReceived, ) * fiatPrice ).toFixed(2)}{' '} - USD + { + currency.fiatCurrencies + .fiatCurrency + } )} diff --git a/web/cashtab/src/components/Wallet/TxHistory.js b/web/cashtab/src/components/Wallet/TxHistory.js --- a/web/cashtab/src/components/Wallet/TxHistory.js +++ b/web/cashtab/src/components/Wallet/TxHistory.js @@ -4,7 +4,7 @@ export const TxLink = styled.a``; -const TxHistory = ({ txs, fiatPrice }) => { +const TxHistory = ({ txs, fiatPrice, fiatCurrency }) => { return (
{txs.map(tx => ( @@ -14,7 +14,11 @@ target="_blank" rel="noreferrer" > - + ))}
diff --git a/web/cashtab/src/components/Wallet/Wallet.js b/web/cashtab/src/components/Wallet/Wallet.js --- a/web/cashtab/src/components/Wallet/Wallet.js +++ b/web/cashtab/src/components/Wallet/Wallet.js @@ -179,7 +179,7 @@ const WalletInfo = () => { const ContextValue = React.useContext(WalletContext); - const { wallet, fiatPrice, apiError } = ContextValue; + const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; let balances; let parsedTxHistory; let tokens; @@ -245,8 +245,19 @@ {fiatPrice !== null && !isNaN(balances.totalBalance) && ( - ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} - USD + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].symbol + } ` + : '$ '} + {(balances.totalBalance * fiatPrice).toFixed(2)}{' '} + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} )} @@ -356,6 +367,11 @@ diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap --- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap +++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap @@ -12,7 +12,7 @@
- $ + $ NaN USD @@ -149,7 +149,7 @@
- $ + $ NaN USD @@ -286,7 +286,7 @@
- $ + $ NaN USD @@ -423,7 +423,7 @@
- $ + $ NaN USD diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -12,6 +12,7 @@ loadStoredWallet, isValidStoredWallet, } from '@utils/cashMethods'; +import { isValidCashtabSettings } from '@utils/validation'; import localforage from 'localforage'; import { currency } from '@components/Common/Ticker'; import isEmpty from 'lodash.isempty'; @@ -19,9 +20,11 @@ const useWallet = () => { const [wallet, setWallet] = useState(false); + const [cashtabSettings, setCashtabSettings] = useState(false); const [fiatPrice, setFiatPrice] = useState(null); const [ws, setWs] = useState(null); const [apiError, setApiError] = useState(false); + const [checkFiatInterval, setCheckFiatInterval] = useState(null); const [walletState, setWalletState] = useState({ balances: {}, hydratedUtxoDetails: {}, @@ -799,6 +802,103 @@ await loadWalletFromStorageOnStartup(setWallet); }; + const loadCashtabSettings = async () => { + // get settings object from localforage + let localSettings; + try { + localSettings = await localforage.getItem('settings'); + // If there is no keyvalue pair in localforage with key 'settings' + if (localSettings === null) { + // Create one with the default settings from Ticker.js + localforage.setItem('settings', currency.defaultSettings); + // Set state to default settings + setCashtabSettings(currency.defaultSettings); + return currency.defaultSettings; + } + } catch (err) { + console.log(`Error getting cashtabSettings`, err); + // TODO If they do not exist, write them + // TODO add function to change them + setCashtabSettings(currency.defaultSettings); + return currency.defaultSettings; + } + // If you found an object in localforage at the settings key, make sure it's valid + if (isValidCashtabSettings(localSettings)) { + setCashtabSettings(localSettings); + return localSettings; + } + // if not valid, also set cashtabSettings to default + setCashtabSettings(currency.defaultSettings); + return currency.defaultSettings; + }; + + // With different currency selections possible, need unique intervals for price checks + // Must be able to end them and set new ones with new currencies + const initializeFiatPriceApi = async selectedFiatCurrency => { + // Update fiat price and confirm it is set to make sure ap keeps loading state until this is updated + await fetchBchPrice(selectedFiatCurrency); + // Set interval for updating the price with given currency + + const thisFiatInterval = setInterval(function () { + fetchBchPrice(selectedFiatCurrency); + }, 60000); + + // set interval in state + setCheckFiatInterval(thisFiatInterval); + }; + + const clearFiatPriceApi = fiatPriceApi => { + // Clear fiat price check interval of previously selected currency + clearInterval(fiatPriceApi); + }; + + const changeCashtabSettings = async (key, newValue) => { + // Set loading to true as you do not want to display the fiat price of the last currency + // loading = true will lock the UI until the fiat price has updated + setLoading(true); + // Get settings from localforage + let currentSettings; + let newSettings; + try { + currentSettings = await localforage.getItem('settings'); + } catch (err) { + console.log(`Error in changeCashtabSettings`, err); + // Set fiat price to null, which disables fiat sends throughout the app + setFiatPrice(null); + // Unlock the UI + setLoading(false); + return; + } + // Make sure function was called with valid params + if ( + Object.keys(currentSettings).includes(key) && + currency.settingsValidation[key].includes(newValue) + ) { + // Update settings + newSettings = currentSettings; + newSettings[key] = newValue; + } + // Set new settings in state so they are available in context throughout the app + setCashtabSettings(newSettings); + // If this settings change adjusted the fiat currency, update fiat price + if (key === 'fiatCurrency') { + clearFiatPriceApi(checkFiatInterval); + initializeFiatPriceApi(newValue); + } + // Write new settings in localforage + try { + await localforage.setItem('settings', newSettings); + } catch (err) { + console.log( + `Error writing newSettings object to localforage in changeCashtabSettings`, + err, + ); + console.log(`newSettings`, newSettings); + // do nothing. If this happens, the user will see default currency next time they load the app. + } + setLoading(false); + }; + // Parse for incoming BCH transactions // Only notify if websocket is not connected if ( @@ -931,11 +1031,6 @@ } } - // Update price every 1 min - useAsyncTimeout(async () => { - fetchBchPrice(); - }, 60000); - // Update wallet every 10s useAsyncTimeout(async () => { const wallet = await getWallet(); @@ -1223,11 +1318,11 @@ } }; - const fetchBchPrice = async () => { + const fetchBchPrice = async ( + fiatCode = cashtabSettings ? cashtabSettings.fiatCurrency : 'usd', + ) => { // Split this variable out in case coingecko changes const cryptoId = currency.coingeckoId; - // Keep currency as a variable as eventually it will be a user setting - const fiatCode = 'usd'; // Keep this in the code, because different URLs will have different outputs require different parsing const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`; let bchPrice; @@ -1265,9 +1360,10 @@ } }; - useEffect(() => { + useEffect(async () => { handleUpdateWallet(setWallet); - fetchBchPrice(); + const initialSettings = await loadCashtabSettings(); + initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); useEffect(() => { @@ -1297,6 +1393,8 @@ parsedTxHistory, loading, apiError, + cashtabSettings, + changeCashtabSettings, getActiveWalletFromLocalForage, getWallet, validateMnemonic, diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -7,6 +7,7 @@ isValidTokenInitialQty, isValidTokenDocumentUrl, isValidTokenStats, + isValidCashtabSettings, } from '../validation'; import { currency } from '@components/Common/Ticker.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; @@ -213,4 +214,15 @@ it(`Recognizes a token stats object with missing required keys as invalid`, () => { expect(isValidTokenStats(noCovidStatsInvalid)).toBe(false); }); + it(`Recognizes a valid cashtab settings object`, () => { + expect(isValidCashtabSettings({ fiatCurrency: 'usd' })).toBe(true); + }); + it(`Rejects a cashtab settings object for an unsupported currency`, () => { + expect(isValidCashtabSettings({ fiatCurrency: 'jpy' })).toBe(false); + }); + it(`Rejects a corrupted cashtab settings object for an unsupported currency`, () => { + expect(isValidCashtabSettings({ fiatCurrencyWrongLabel: 'usd' })).toBe( + false, + ); + }); }); diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -13,8 +13,8 @@ let error = false; let testedAmount = new BigNumber(cashAmount); - if (selectedCurrency === 'USD') { - // Ensure no more than 8 decimal places + if (selectedCurrency !== currency.ticker) { + // Ensure no more than currency.cashDecimals decimal places testedAmount = new BigNumber(fiatToCrypto(cashAmount, fiatPrice)); } @@ -105,3 +105,17 @@ 'circulatingSupply' in tokenStats ); }; + +export const isValidCashtabSettings = settings => { + try { + const isValid = + typeof settings === 'object' && + Object.prototype.hasOwnProperty.call(settings, 'fiatCurrency') && + currency.settingsValidation.fiatCurrency.includes( + settings.fiatCurrency, + ); + return isValid; + } catch (err) { + return false; + } +};