diff --git a/src-gui/src/renderer/api.ts b/src-gui/src/renderer/api.ts index e0438920e..d40861b92 100644 --- a/src-gui/src/renderer/api.ts +++ b/src-gui/src/renderer/api.ts @@ -5,6 +5,9 @@ // - and to submit feedback // - fetch currency rates from CoinGecko import { Alert, ExtendedProviderStatus } from "models/apiModel"; +import { store } from "./store/storeRenderer"; +import { setBtcPrice, setXmrBtcRate, setXmrPrice } from "store/features/ratesSlice"; +import { FiatCurrency } from "store/features/settingsSlice"; const API_BASE_URL = "https://api.unstoppableswap.net"; @@ -45,20 +48,20 @@ export async function submitFeedbackViaHttp( return responseBody.feedbackId; } -async function fetchCurrencyUsdPrice(currency: string): Promise { +async function fetchCurrencyPrice(currency: string, fiatCurrency: FiatCurrency): Promise { try { const response = await fetch( - `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=usd`, + `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=${fiatCurrency.toLowerCase()}`, ); const data = await response.json(); - return data[currency].usd; + return data[currency][fiatCurrency.toLowerCase()]; } catch (error) { console.error(`Error fetching ${currency} price:`, error); throw error; } } -export async function fetchXmrBtcRate(): Promise { +async function fetchXmrBtcRate(): Promise { try { const response = await fetch('https://api.kraken.com/0/public/Ticker?pair=XMRXBT'); const data = await response.json(); @@ -78,10 +81,35 @@ export async function fetchXmrBtcRate(): Promise { } -export async function fetchBtcPrice(): Promise { - return fetchCurrencyUsdPrice("bitcoin"); +async function fetchBtcPrice(fiatCurrency: FiatCurrency): Promise { + return fetchCurrencyPrice("bitcoin", fiatCurrency); } -export async function fetchXmrPrice(): Promise { - return fetchCurrencyUsdPrice("monero"); -} \ No newline at end of file +async function fetchXmrPrice(fiatCurrency: FiatCurrency): Promise { + return fetchCurrencyPrice("monero", fiatCurrency); +} + +/** + * If enabled by the user, fetch the XMR, BTC and XMR/BTC rates + * and store them in the Redux store. + */ +export async function updateRates(): Promise { + const settings = store.getState().settings; + if (!settings.fetchFiatPrices) + return; + + console.log(`currency: ${settings.fiatCurrency}`); + + try { + const btcPrice = await fetchBtcPrice(settings.fiatCurrency); + store.dispatch(setBtcPrice(btcPrice)); + + const xmrPrice = await fetchXmrPrice(settings.fiatCurrency); + store.dispatch(setXmrPrice(xmrPrice)); + + const xmrBtcRate = await fetchXmrBtcRate(); + store.dispatch(setXmrBtcRate(xmrBtcRate)); + } catch (error) { + console.error("Error fetching rates:", error); + } +} diff --git a/src-gui/src/renderer/components/App.tsx b/src-gui/src/renderer/components/App.tsx index b8bfac5c8..ed685aa6a 100644 --- a/src-gui/src/renderer/components/App.tsx +++ b/src-gui/src/renderer/components/App.tsx @@ -11,14 +11,9 @@ import GlobalSnackbarProvider from "./snackbar/GlobalSnackbarProvider"; import { useSettings } from "store/hooks"; import { themes } from "./theme"; import { initEventListeners } from "renderer/rpc"; -import { fetchAlertsViaHttp, fetchBtcPrice, fetchProvidersViaHttp } from "renderer/api"; -import { setXmrPrice } from "store/features/ratesSlice"; +import { fetchAlertsViaHttp, fetchProvidersViaHttp, updateRates } from "renderer/api"; import { store } from "renderer/store/storeRenderer"; -import { setBtcPrice } from "store/features/ratesSlice"; -import { fetchXmrPrice } from "renderer/api"; import logger from "utils/logger"; -import { setXmrBtcRate } from "store/features/ratesSlice"; -import { fetchXmrBtcRate } from "renderer/api"; import { setAlerts } from "store/features/alertsSlice"; import { setRegistryProviders } from "store/features/providersSlice"; import { registryConnectionFailed } from "store/features/providersSlice"; @@ -94,23 +89,13 @@ async function fetchInitialData() { logger.error(e, "Failed to fetch alerts via UnstoppableSwap HTTP API"); } + // Update XMR/BTC rates immediately and then at regular intervals try { - const xmrPrice = await fetchXmrPrice(); - store.dispatch(setXmrPrice(xmrPrice)); - logger.info({ xmrPrice }, "Fetched XMR price"); - - const btcPrice = await fetchBtcPrice(); - store.dispatch(setBtcPrice(btcPrice)); - logger.info({ btcPrice }, "Fetched BTC price"); + await updateRates(); } catch (e) { logger.error(e, "Error retrieving fiat prices"); } - try { - const xmrBtcRate = await fetchXmrBtcRate(); - store.dispatch(setXmrBtcRate(xmrBtcRate)); - logger.info({ xmrBtcRate }, "Fetched XMR/BTC rate"); - } catch (e) { - logger.error(e, "Error retrieving XMR/BTC rate"); - } -} \ No newline at end of file + const UPDATE_INTERVAL = 30_000; + setInterval(updateRates, UPDATE_INTERVAL); +} diff --git a/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx b/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx index 525f72215..7800a19d0 100644 --- a/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx +++ b/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx @@ -37,8 +37,8 @@ function ProviderSpreadChip({ provider }: { provider: ExtendedProviderStatus }) const spread = ((providerPrice - xmrBtcPrice) / xmrBtcPrice) * 100; return ( - - + + ); @@ -74,8 +74,8 @@ export default function ProviderInfo({ {provider.uptime && ( - - + + )} {provider.age ? ( @@ -93,7 +93,7 @@ export default function ProviderInfo({ )} {isOutdated && ( - + } color="primary" /> )} diff --git a/src-gui/src/renderer/components/other/Units.tsx b/src-gui/src/renderer/components/other/Units.tsx index 41c787135..d22a8a1f6 100644 --- a/src-gui/src/renderer/components/other/Units.tsx +++ b/src-gui/src/renderer/components/other/Units.tsx @@ -8,16 +8,18 @@ export function AmountWithUnit({ amount, unit, fixedPrecision, - dollarRate, + exchangeRate, }: { amount: Amount; unit: string; fixedPrecision: number; - dollarRate?: Amount; + exchangeRate?: Amount; }) { + const fetchFiatPrices = useAppSelector((state) => state.settings.fetchFiatPrices); + const fiatCurrency = useAppSelector((state) => state.settings.fiatCurrency); const title = - dollarRate != null && amount != null - ? `≈ $${(dollarRate * amount).toFixed(2)}` + fetchFiatPrices && exchangeRate != null && amount != null && fiatCurrency != null + ? `≈ ${(exchangeRate * amount).toFixed(2)} ${fiatCurrency}` : ""; return ( @@ -33,31 +35,31 @@ export function AmountWithUnit({ } AmountWithUnit.defaultProps = { - dollarRate: null, + exchangeRate: null, }; export function BitcoinAmount({ amount }: { amount: Amount }) { - const btcUsdRate = useAppSelector((state) => state.rates.btcPrice); + const btcRate = useAppSelector((state) => state.rates.btcPrice); return ( ); } export function MoneroAmount({ amount }: { amount: Amount }) { - const xmrUsdRate = useAppSelector((state) => state.rates.xmrPrice); + const xmrRate = useAppSelector((state) => state.rates.xmrPrice); return ( ); } diff --git a/src-gui/src/renderer/components/other/ValidatedTextField.tsx b/src-gui/src/renderer/components/other/ValidatedTextField.tsx index 530d805f4..bae766e9f 100644 --- a/src-gui/src/renderer/components/other/ValidatedTextField.tsx +++ b/src-gui/src/renderer/components/other/ValidatedTextField.tsx @@ -6,6 +6,7 @@ interface ValidatedTextFieldProps extends Omit boolean; onValidatedChange: (value: string | null) => void; allowEmpty?: boolean; + noErrorWhenEmpty?: boolean; helperText?: string; } @@ -17,6 +18,7 @@ export default function ValidatedTextField({ helperText = "Invalid input", variant = "standard", allowEmpty = false, + noErrorWhenEmpty = false, ...props }: ValidatedTextFieldProps) { const [inputValue, setInputValue] = useState(value || ""); @@ -39,7 +41,7 @@ export default function ValidatedTextField({ setInputValue(value || ""); }, [value]); - const isError = allowEmpty && inputValue === "" ? false : !isValid(inputValue); + const isError = allowEmpty && inputValue === "" || inputValue === "" && noErrorWhenEmpty ? false : !isValid(inputValue); return (

As part of the Monero Community Crowdfunding System (CCS), we received funding for 6 months of full-time development by - generous donors from the Monero community (Link). + generous donors from the Monero community (link).

If you want to support our effort event further, you can do so at this address. diff --git a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx index d2585846e..c83c9e63a 100644 --- a/src-gui/src/renderer/components/pages/help/SettingsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/SettingsBox.tsx @@ -19,16 +19,19 @@ import { DialogActions, DialogTitle, useTheme, - DialogContentText, + Switch, } from "@material-ui/core"; import InfoBox from "renderer/components/modal/swap/InfoBox"; import { addNode, Blockchain, + FiatCurrency, moveUpNode, Network, removeNode, resetSettings, + setFetchFiatPrices, + setFiatCurrency, setTheme, } from "store/features/settingsSlice"; import { useAppDispatch, useAppSelector, useNodes, useSettings } from "store/hooks"; @@ -39,6 +42,7 @@ import { Theme } from "renderer/components/theme"; import { getNetwork } from "store/config"; import { Add, ArrowUpward, Delete, Edit, HourglassEmpty } from "@material-ui/icons"; import { updateAllNodeStatuses } from "renderer/rpc"; +import { updateRates } from "renderer/api"; const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700"; const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081"; @@ -51,12 +55,33 @@ const useStyles = makeStyles((theme) => ({ } })); -export default function SettingsBox() { +function ResetButton() { const dispatch = useAppDispatch(); + const [modalOpen, setModalOpen] = useState(false); + + const onReset = () => { + dispatch(resetSettings()); + setModalOpen(false); + }; + + return ( + <> + +

setModalOpen(false)}> + Reset Settings + Are you sure you want to reset the settings? + + + + + + + ) +} + +export default function SettingsBox() { const classes = useStyles(); const theme = useTheme(); - const [resetModalOpen, setResetModalOpen] = useState(false); - return ( + - - setResetModalOpen(false)} - > - Reset Settings - - - Are you sure you want to reset all settings to their default values? This cannot be undone. - - - - - - - + } mainContent={ @@ -124,6 +117,58 @@ export default function SettingsBox() { ); } +function FetchFiatPricesSetting() { + const fetchFiatPrices = useSettings((s) => s.fetchFiatPrices); + const dispatch = useAppDispatch(); + + return ( + <> + + + + + + dispatch(setFetchFiatPrices(event.currentTarget.checked))} + /> + + + { fetchFiatPrices ? : <> } + + ); +} + +function FiatCurrencySetting() { + const fiatCurrency = useSettings((s) => s.fiatCurrency); + const dispatch = useAppDispatch(); + const onChange = (e: React.ChangeEvent<{ value: unknown }>) => { + dispatch(setFiatCurrency(e.target.value as FiatCurrency)); + updateRates(); + } + + return ( + + + + + + + + + ); +} + // URL validation function, forces the URL to be in the format of "protocol://host:port/" function isValidUrl(url: string, allowedProtocols: string[]): boolean { const urlPattern = new RegExp(`^(${allowedProtocols.join("|")})://[^\\s]+:\\d+/?$`); @@ -163,7 +208,6 @@ function ElectrumRpcUrlSetting() { ); } - function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | null }) { return @@ -226,6 +270,7 @@ function ThemeSetting() { value={theme} onChange={(e) => dispatch(setTheme(e.target.value as Theme))} variant="outlined" + fullWidth > {/** Create an option for each theme variant */} {Object.values(Theme).map((themeValue) => ( @@ -355,12 +400,9 @@ function NodeTable({
{ currentNode !== node ? - { - while (currentNode !== node) { - dispatch(moveUpNode({ network, type: blockchain, node })); - await new Promise((resolve) => setTimeout(resolve, 50)); - } - }}> + + dispatch(moveUpNode({ network, type: blockchain, node })) + }> : <>} @@ -375,19 +417,15 @@ function NodeTable({ onValidatedChange={setNewNode} placeholder={placeholder} fullWidth - allowEmpty isValid={isValid} variant="outlined" - onKeyUp={(e) => { - if (e.key === 'Enter' && newNode) - addNewNode(); - }} + noErrorWhenEmpty /> - + diff --git a/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx b/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx index e480eb3ef..32e5933ed 100644 --- a/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx +++ b/src-gui/src/renderer/components/pages/wallet/WithdrawWidget.tsx @@ -1,6 +1,5 @@ import { Box, Button, makeStyles, Typography } from "@material-ui/core"; import SendIcon from "@material-ui/icons/Send"; -import { RpcMethod } from "models/rpcModel"; import { useState } from "react"; import { SatsAmount } from "renderer/components/other/Units"; import { useAppSelector } from "store/hooks"; diff --git a/src-gui/src/store/features/ratesSlice.ts b/src-gui/src/store/features/ratesSlice.ts index fbac52f91..2975cb99f 100644 --- a/src-gui/src/store/features/ratesSlice.ts +++ b/src-gui/src/store/features/ratesSlice.ts @@ -20,12 +20,15 @@ const ratesSlice = createSlice({ initialState, reducers: { setBtcPrice: (state, action: PayloadAction) => { + console.log("setBtcPrice", action.payload); state.btcPrice = action.payload; }, setXmrPrice: (state, action: PayloadAction) => { + console.log("setXmrPrice", action.payload); state.xmrPrice = action.payload; }, setXmrBtcRate: (state, action: PayloadAction) => { + console.log("setXmrBtcRate", action.payload); state.xmrBtcRate = action.payload; }, }, diff --git a/src-gui/src/store/features/settingsSlice.ts b/src-gui/src/store/features/settingsSlice.ts index c2447fe12..d7c83083e 100644 --- a/src-gui/src/store/features/settingsSlice.ts +++ b/src-gui/src/store/features/settingsSlice.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { TauriSettings } from "models/tauriModel"; +import { updateRates } from "renderer/api"; import { Theme } from "renderer/components/theme"; export interface SettingsState { @@ -7,6 +7,17 @@ export interface SettingsState { nodes: Record>; /// Which theme to use theme: Theme; + /// Whether to fetch fiat prices from the internet + fetchFiatPrices: boolean; + fiatCurrency: FiatCurrency; +} + +export enum FiatCurrency { + Usd = "USD", + Eur = "EUR", + Gbp = "GBP", + Chf = "CHF", + Jpy = "JPY", } export enum Network { @@ -30,7 +41,9 @@ const initialState: SettingsState = { [Blockchain.Monero]: [] } }, - theme: Theme.Dark + theme: Theme.Dark, + fetchFiatPrices: false, + fiatCurrency: FiatCurrency.Usd, }; const alertsSlice = createSlice({ @@ -48,6 +61,15 @@ const alertsSlice = createSlice({ setTheme(slice, action: PayloadAction) { slice.theme = action.payload; }, + setFetchFiatPrices(slice, action: PayloadAction) { + if (action.payload === true) + try { updateRates() } catch (_) {} + slice.fetchFiatPrices = action.payload; + }, + setFiatCurrency(slice, action: PayloadAction) { + console.log("setFiatCurrency", action.payload); + slice.fiatCurrency = action.payload; + }, addNode(slice, action: PayloadAction<{ network: Network, type: Blockchain, node: string }>) { // Make sure the node is not already in the list if (slice.nodes[action.payload.network][action.payload.type].includes(action.payload.node)) { @@ -70,6 +92,8 @@ export const { setTheme, addNode, removeNode, - resetSettings + resetSettings, + setFetchFiatPrices, + setFiatCurrency, } = alertsSlice.actions; export default alertsSlice.reducer; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 2a61c54a5..1764b2d81 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -5,9 +5,9 @@ import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import { parseDateString } from "utils/parseUtils"; import { useMemo } from "react"; import { isCliLogRelatedToSwap } from "models/cliModel"; -import { TauriSettings } from "models/tauriModel"; import { SettingsState } from "./features/settingsSlice"; import { NodesSlice } from "./features/nodesSlice"; +import { RatesState } from "./features/ratesSlice"; export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; @@ -86,6 +86,11 @@ export function useSwapInfosSortedByDate() { ); } +export function useRates(selector: (rates: RatesState) => T): T { + const rates = useAppSelector((state) => state.rates); + return selector(rates); +} + export function useSettings(selector: (settings: SettingsState) => T): T { const settings = useAppSelector((state) => state.settings); return selector(settings);