diff --git a/src/app/components/BudgetControl/index.tsx b/src/app/components/BudgetControl/index.tsx index bf684228da..c32e5e0040 100644 --- a/src/app/components/BudgetControl/index.tsx +++ b/src/app/components/BudgetControl/index.tsx @@ -9,8 +9,8 @@ type Props = { onRememberChange: ChangeEventHandler; budget: string; onBudgetChange: ChangeEventHandler; - fiatAmount: string; disabled?: boolean; + showFiat?: boolean; }; function BudgetControl({ @@ -18,15 +18,13 @@ function BudgetControl({ onRememberChange, budget, onBudgetChange, - fiatAmount, disabled = false, + showFiat = false, }: Props) { const { t } = useTranslation("components", { keyPrefix: "budget_control", }); - const { t: tCommon } = useTranslation("common"); - return (
@@ -60,12 +58,11 @@ function BudgetControl({
diff --git a/src/app/components/PaymentSummary/index.test.tsx b/src/app/components/PaymentSummary/index.test.tsx index ecac0672b8..2ec32ff9cd 100644 --- a/src/app/components/PaymentSummary/index.test.tsx +++ b/src/app/components/PaymentSummary/index.test.tsx @@ -12,7 +12,7 @@ jest.mock("~/common/lib/api", () => { return { ...original, getSettings: jest.fn(() => Promise.resolve(mockSettings)), - getCurrencyRate: jest.fn(() => Promise.resolve({ rate: 11 })), + getCurrencyRate: jest.fn(() => 11), }; }); diff --git a/src/app/components/SitePreferences/index.test.tsx b/src/app/components/SitePreferences/index.test.tsx index 75da970ec8..a4198d8b38 100644 --- a/src/app/components/SitePreferences/index.test.tsx +++ b/src/app/components/SitePreferences/index.test.tsx @@ -6,16 +6,20 @@ import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; import type { Props } from "./index"; import SitePreferences from "./index"; -const mockGetFiatValue = jest.fn(() => Promise.resolve("$1,22")); +const mockGetFormattedFiat = jest.fn(() => "$1,22"); +const mockGetFormattedInCurrency = jest.fn((v, curr) => v + " " + curr); jest.mock("~/app/context/SettingsContext", () => ({ useSettings: () => ({ settings: mockSettings, isLoading: false, updateSetting: jest.fn(), - getFormattedFiat: mockGetFiatValue, + getFormattedFiat: mockGetFormattedFiat, getFormattedNumber: jest.fn(), getFormattedSats: jest.fn(), + getCurrencyRate: jest.fn(() => 1), + getCurrencySymbol: jest.fn(() => "₿"), + getFormattedInCurrency: mockGetFormattedInCurrency, }), })); @@ -53,7 +57,7 @@ describe("SitePreferences", () => { await renderComponent(); - expect(mockGetFiatValue).not.toHaveBeenCalled(); + expect(mockGetFormattedFiat).not.toHaveBeenCalled(); const settingsButton = await screen.getByRole("button"); @@ -66,25 +70,59 @@ describe("SitePreferences", () => { name: "Save", }); + const checkDualInputValues = (values: Array<[number, string]>) => { + for (let i = 0; i < values.length; i++) { + expect(mockGetFormattedInCurrency).toHaveBeenNthCalledWith( + i + 1, + ...values[i] + ); + } + expect(mockGetFormattedInCurrency).toHaveBeenCalledTimes(values.length); + }; + + const checkDualInputValue = (v: number, n: number) => { + for (let i = 1; i <= n * 2; i += 2) { + expect(mockGetFormattedInCurrency).toHaveBeenNthCalledWith(i, v, "BTC"); + expect(mockGetFormattedInCurrency).toHaveBeenNthCalledWith( + i + 1, + v, + "USD" + ); + } + expect(mockGetFormattedInCurrency).toHaveBeenCalledTimes(n * 2); + }; + // update fiat value when modal is open - expect(mockGetFiatValue).toHaveBeenCalledWith( - defaultProps.allowance.totalBudget.toString() - ); - expect(mockGetFiatValue).toHaveBeenCalledTimes(1); + checkDualInputValue(defaultProps.allowance.totalBudget, 2); await act(async () => { await user.clear(screen.getByLabelText("One-click payments budget")); + mockGetFormattedInCurrency.mockClear(); await user.type( screen.getByLabelText("One-click payments budget"), "250" ); }); + // update fiat value expect(screen.getByLabelText("One-click payments budget")).toHaveValue(250); - // update fiat value - expect(mockGetFiatValue).toHaveBeenCalledWith("250"); - expect(mockGetFiatValue).toHaveBeenCalledTimes(4); // plus 3 times for each input value 2, 5, 0 + checkDualInputValues([ + [2, "BTC"], + [2, "USD"], + [2, "BTC"], + [2, "USD"], + [25, "BTC"], + [25, "USD"], + [25, "BTC"], + [25, "USD"], + [250, "BTC"], + [250, "USD"], + [250, "BTC"], + [250, "USD"], + [250, "BTC"], + [250, "USD"], + ]); await act(async () => { await user.click(saveButton); diff --git a/src/app/components/SitePreferences/index.tsx b/src/app/components/SitePreferences/index.tsx index ae17f50d0e..bc5cb84476 100644 --- a/src/app/components/SitePreferences/index.tsx +++ b/src/app/components/SitePreferences/index.tsx @@ -26,17 +26,12 @@ export type Props = { }; function SitePreferences({ launcherType, allowance, onEdit, onDelete }: Props) { - const { - isLoading: isLoadingSettings, - settings, - getFormattedFiat, - } = useSettings(); + const { isLoading: isLoadingSettings, settings } = useSettings(); const showFiat = !isLoadingSettings && settings.showFiat; const { account } = useAccount(); const [modalIsOpen, setIsOpen] = useState(false); const [budget, setBudget] = useState(""); const [lnurlAuth, setLnurlAuth] = useState(false); - const [fiatAmount, setFiatAmount] = useState(""); const [originalPermissions, setOriginalPermissions] = useState< Permission[] | null @@ -83,17 +78,6 @@ function SitePreferences({ launcherType, allowance, onEdit, onDelete }: Props) { fetchPermissions(); }, [account?.id, allowance.id]); - useEffect(() => { - if (budget !== "" && showFiat) { - const getFiat = async () => { - const res = await getFormattedFiat(budget); - setFiatAmount(res); - }; - - getFiat(); - } - }, [budget, showFiat, getFormattedFiat]); - function openModal() { setBudget(allowance.totalBudget.toString()); setLnurlAuth(allowance.lnurlAuth); @@ -238,10 +222,9 @@ function SitePreferences({ launcherType, allowance, onEdit, onDelete }: Props) { label={t("new_budget.label")} min={0} autoFocus - placeholder={tCommon("sats", { count: 0 })} value={budget} hint={t("hint")} - fiatValue={fiatAmount} + showFiat={showFiat} onChange={(e) => setBudget(e.target.value)} />
diff --git a/src/app/components/form/DualCurrencyField/index.test.tsx b/src/app/components/form/DualCurrencyField/index.test.tsx index ff8a4909e6..ccef6a5f9c 100644 --- a/src/app/components/form/DualCurrencyField/index.test.tsx +++ b/src/app/components/form/DualCurrencyField/index.test.tsx @@ -1,13 +1,27 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; import type { Props } from "./index"; import DualCurrencyField from "./index"; const props: Props = { - fiatValue: "$10.00", + showFiat: true, label: "Amount", }; +jest.mock("~/app/context/SettingsContext", () => ({ + useSettings: () => ({ + settings: mockSettings, + isLoading: false, + updateSetting: jest.fn(), + getFormattedFiat: jest.fn(() => "$10.00"), + getFormattedNumber: jest.fn(), + getFormattedSats: jest.fn(), + getCurrencyRate: jest.fn(() => 1), + getCurrencySymbol: jest.fn(() => "₿"), + getFormattedInCurrency: jest.fn(() => "$10.00"), + }), +})); describe("DualCurrencyField", () => { test("render", async () => { @@ -20,6 +34,9 @@ describe("DualCurrencyField", () => { const input = screen.getByLabelText("Amount"); expect(input).toBeInTheDocument(); - expect(await screen.getByText("~$10.00")).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("~$10.00")).toBeInTheDocument(); + }); }); }); diff --git a/src/app/components/form/DualCurrencyField/index.tsx b/src/app/components/form/DualCurrencyField/index.tsx index d7d443992e..8dcc4ae047 100644 --- a/src/app/components/form/DualCurrencyField/index.tsx +++ b/src/app/components/form/DualCurrencyField/index.tsx @@ -1,22 +1,36 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useAccount } from "~/app/context/AccountContext"; +import { useSettings } from "~/app/context/SettingsContext"; import { classNames } from "~/app/utils"; - import { RangeLabel } from "./rangeLabel"; +export type DualCurrencyFieldChangeEventTarget = HTMLInputElement & { + valueInFiat: number; // current value converted to fiat + formattedValueInFiat: string; // current value in fiat formatted (e.g. $10.00) + valueInSats: number; // current value in sats + formattedValueInSats: string; // current value in sats formatted (e.g. 1000 sats) +}; + +export type DualCurrencyFieldChangeEvent = + React.ChangeEvent & { + target: DualCurrencyFieldChangeEventTarget; + }; + export type Props = { suffix?: string; endAdornment?: React.ReactNode; - fiatValue: string; label: string; hint?: string; amountExceeded?: boolean; rangeExceeded?: boolean; + showFiat?: boolean; // compute and show fiat value + onChange?: (e: DualCurrencyFieldChangeEvent) => void; }; export default function DualCurrencyField({ label, - fiatValue, + showFiat = true, id, placeholder, required = false, @@ -38,10 +52,204 @@ export default function DualCurrencyField({ rangeExceeded, }: React.InputHTMLAttributes & Props) { const { t: tCommon } = useTranslation("common"); + const { + getFormattedInCurrency, + getCurrencyRate, + getCurrencySymbol, + settings, + } = useSettings(); + const { account } = useAccount(); + const inputEl = useRef(null); const outerStyles = "rounded-md border border-gray-300 dark:border-gray-800 bg-white dark:bg-black transition duration-300"; + const initialized = useRef(false); + const [useFiatAsMain, _setUseFiatAsMain] = useState(false); + const [altFormattedValue, setAltFormattedValue] = useState(""); + const [minValue, setMinValue] = useState(min); + const [maxValue, setMaxValue] = useState(max); + const [inputValue, setInputValue] = useState(value); + const [inputPrefix, setInputPrefix] = useState(""); + const [inputPlaceHolder, setInputPlaceHolder] = useState(placeholder || ""); + const [lastSeenInputValue, setLastSeenInputValue] = useState(value); + + // Perform currency conversions for the input value + // always returns formatted and raw values in sats and fiat + const convertValues = useCallback( + async (inputValue: number, inputInFiat: boolean) => { + const userCurrency = settings?.currency || "BTC"; + let valueInSats = 0; + let valueInFiat = 0; + const rate = await getCurrencyRate(); + + if (inputInFiat) { + valueInFiat = Number(inputValue); + valueInSats = Math.round(valueInFiat / rate); + } else { + valueInSats = Number(inputValue); + valueInFiat = Math.round(valueInSats * rate * 10000) / 10000.0; + } + + const formattedSats = getFormattedInCurrency(valueInSats, "BTC"); + const formattedFiat = getFormattedInCurrency(valueInFiat, userCurrency); + + return { + valueInSats, + formattedSats, + valueInFiat, + formattedFiat, + }; + }, + [getCurrencyRate, getFormattedInCurrency, settings] + ); + + // Use fiat as main currency for the input + const setUseFiatAsMain = useCallback( + async (useFiatAsMain: boolean, recalculateValue: boolean = true) => { + if (!showFiat) useFiatAsMain = false; + const userCurrency = settings?.currency || "BTC"; + const rate = await getCurrencyRate(); + + if (min) { + setMinValue( + useFiatAsMain + ? (Math.round(Number(min) * rate * 10000) / 10000.0).toString() + : min + ); + } + + if (max) { + setMaxValue( + useFiatAsMain + ? (Math.round(Number(max) * rate * 10000) / 10000.0).toString() + : max + ); + } + + _setUseFiatAsMain(useFiatAsMain); + if (recalculateValue) { + const newValue = useFiatAsMain + ? Math.round(Number(inputValue) * rate * 10000) / 10000.0 + : Math.round(Number(inputValue) / rate); + setInputValue(newValue); + } + setInputPrefix(getCurrencySymbol(useFiatAsMain ? userCurrency : "BTC")); + if (!placeholder) { + setInputPlaceHolder( + tCommon("amount_placeholder", { + currency: useFiatAsMain ? userCurrency : "sats", + }) + ); + } + }, + [ + settings, + showFiat, + getCurrencyRate, + inputValue, + min, + max, + tCommon, + getCurrencySymbol, + placeholder, + ] + ); + + // helper to swap currencies (btc->fiat fiat->btc) + const swapCurrencies = () => { + setUseFiatAsMain(!useFiatAsMain); + }; + + // This wraps the onChange event and converts input values + const onChangeWrapper = useCallback( + async (e: React.ChangeEvent) => { + setInputValue(e.target.value); + + if (onChange) { + const wrappedEvent: DualCurrencyFieldChangeEvent = + e as DualCurrencyFieldChangeEvent; + + // Convert and inject the converted values into the event + const value = Number(e.target.value); + const { valueInSats, formattedSats, valueInFiat, formattedFiat } = + await convertValues(value, useFiatAsMain); + + // we need to clone the target to avoid side effects on react internals + wrappedEvent.target = + e.target.cloneNode() as DualCurrencyFieldChangeEventTarget; + // ensure the value field is always in sats, this allows the code using this component + // to "reason in sats" and not have to worry about the user's currency + wrappedEvent.target.value = valueInSats.toString(); + wrappedEvent.target.valueInFiat = valueInFiat; + wrappedEvent.target.formattedValueInFiat = formattedFiat; + wrappedEvent.target.valueInSats = valueInSats; + wrappedEvent.target.formattedValueInSats = formattedSats; + // Call the original onChange callback + onChange(wrappedEvent); + } + }, + [onChange, useFiatAsMain, convertValues] + ); + + // default to fiat when account currency is set to anything other than BTC + useEffect(() => { + if (!initialized.current) { + const initializeFiatMain = !!( + account?.currency && account?.currency !== "BTC" + ); + setUseFiatAsMain(initializeFiatMain, initializeFiatMain); + initialized.current = true; + } + }, [account?.currency, setUseFiatAsMain]); + + // update alt value + useEffect(() => { + (async () => { + if (showFiat) { + const { formattedSats, formattedFiat } = await convertValues( + Number(inputValue || 0), + useFiatAsMain + ); + setAltFormattedValue(useFiatAsMain ? formattedSats : formattedFiat); + } + })(); + }, [useFiatAsMain, inputValue, convertValues, showFiat]); + + // update input value when the value prop changes + useEffect(() => { + const newValue = Number(value || "0"); + const lastSeenValue = Number(lastSeenInputValue || "0"); + const currentValue = Number(inputValue || "0"); + const currentValueIsFiat = useFiatAsMain; + (async (newValue, lastSeenValue, currentValue, currentValueIsFiat) => { + const { valueInSats } = await convertValues( + currentValue, + currentValueIsFiat + ); + currentValue = Number(valueInSats); + // if the new value is different than the last seen value, it means it value was changes externally + if (newValue != lastSeenValue) { + // update the last seen value + setLastSeenInputValue(newValue.toString()); + // update input value unless the new value is equals to the current input value converted to sats + // (this means the external cose is passing the value from onChange to the input value) + if (newValue != currentValue) { + // Apply conversion for the input value + const { valueInSats, valueInFiat } = await convertValues( + Number(value), + false + ); + if (useFiatAsMain) { + setInputValue(valueInFiat); + } else { + setInputValue(valueInSats); + } + } + } + })(newValue, lastSeenValue, currentValue, currentValueIsFiat); + }, [value, lastSeenInputValue, inputValue, convertValues, useFiatAsMain]); + const inputNode = ( ); @@ -90,14 +299,15 @@ export default function DualCurrencyField({ > {label} - {(min || max) && ( + {(minValue || maxValue) && ( - {tCommon("sats_other")} + {" "} + {useFiatAsMain ? "" : tCommon("sats_other")} )}
@@ -112,17 +322,30 @@ export default function DualCurrencyField({ outerStyles )} > + {!!inputPrefix && ( +

+ {inputPrefix} +

+ )} + {inputNode} - {!!fiatValue && ( -

- ~{fiatValue} + {!!altFormattedValue && ( +

+ {!useFiatAsMain && "~"} + {altFormattedValue}

)} {suffix && ( { inputEl.current?.focus(); }} diff --git a/src/app/context/SettingsContext.tsx b/src/app/context/SettingsContext.tsx index 64ced88319..52db131475 100644 --- a/src/app/context/SettingsContext.tsx +++ b/src/app/context/SettingsContext.tsx @@ -7,6 +7,7 @@ import { ACCOUNT_CURRENCIES, CURRENCIES } from "~/common/constants"; import api from "~/common/lib/api"; import { DEFAULT_SETTINGS } from "~/common/settings"; import { + getCurrencySymbol as getCurrencySymbolUtil, getFormattedCurrency as getFormattedCurrencyUtil, getFormattedFiat as getFormattedFiatUtil, getFormattedNumber as getFormattedNumberUtil, @@ -21,10 +22,12 @@ interface SettingsContextType { getFormattedFiat: (amount: number | string) => Promise; getFormattedSats: (amount: number | string) => string; getFormattedNumber: (amount: number | string) => string; + getCurrencySymbol: (currency: CURRENCIES | ACCOUNT_CURRENCIES) => string; getFormattedInCurrency: ( amount: number | string, - currency?: ACCOUNT_CURRENCIES + currency?: ACCOUNT_CURRENCIES | CURRENCIES ) => string; + getCurrencyRate: () => Promise; } type Setting = Partial; @@ -115,7 +118,7 @@ export const SettingsProvider = ({ const getFormattedInCurrency = ( amount: number | string, - currency = "BTC" as ACCOUNT_CURRENCIES + currency = "BTC" as ACCOUNT_CURRENCIES | CURRENCIES ) => { if (currency === "BTC") { return getFormattedSats(amount); @@ -128,6 +131,13 @@ export const SettingsProvider = ({ }); }; + const getCurrencySymbol = (currency: CURRENCIES | ACCOUNT_CURRENCIES) => { + return getCurrencySymbolUtil({ + currency, + locale: settings.locale, + }); + }; + // update locale on every change useEffect(() => { i18n.changeLanguage(settings.locale); @@ -149,6 +159,8 @@ export const SettingsProvider = ({ getFormattedSats, getFormattedNumber, getFormattedInCurrency, + getCurrencyRate, + getCurrencySymbol, settings, updateSetting, isLoading, diff --git a/src/app/screens/ConfirmKeysend/index.test.tsx b/src/app/screens/ConfirmKeysend/index.test.tsx index 618399a1bf..8c22264201 100644 --- a/src/app/screens/ConfirmKeysend/index.test.tsx +++ b/src/app/screens/ConfirmKeysend/index.test.tsx @@ -4,14 +4,16 @@ import { MemoryRouter } from "react-router-dom"; import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; import type { OriginData } from "~/types"; +import { waitFor } from "@testing-library/react"; import ConfirmKeysend from "./index"; const mockGetFiatValue = jest .fn() - .mockImplementationOnce(() => Promise.resolve("$0.00")) - .mockImplementationOnce(() => Promise.resolve("$0.00")) - .mockImplementationOnce(() => Promise.resolve("$0.01")) - .mockImplementationOnce(() => Promise.resolve("$0.05")); + .mockImplementationOnce(() => "$0.00") + .mockImplementationOnce(() => "$0.01") + .mockImplementationOnce(() => "$0.05"); + +const getFormattedInCurrency = jest.fn((v, c) => "$0.05"); jest.mock("~/app/context/SettingsContext", () => ({ useSettings: () => ({ @@ -21,6 +23,9 @@ jest.mock("~/app/context/SettingsContext", () => ({ getFormattedNumber: jest.fn(), getFormattedSats: jest.fn(() => "21 sats"), getFormattedFiat: mockGetFiatValue, + getFormattedInCurrency: getFormattedInCurrency, + getCurrencyRate: jest.fn(() => 11), + getCurrencySymbol: jest.fn(() => "₿"), }), })); @@ -95,6 +100,8 @@ describe("ConfirmKeysend", () => { const input = await screen.findByLabelText("Budget"); expect(input).toHaveValue(amount * 10); - expect(screen.getByText("~$0.05")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("~$0.05")).toBeInTheDocument(); + }); }); }); diff --git a/src/app/screens/ConfirmKeysend/index.tsx b/src/app/screens/ConfirmKeysend/index.tsx index 34c3de533d..1b7b9217ad 100644 --- a/src/app/screens/ConfirmKeysend/index.tsx +++ b/src/app/screens/ConfirmKeysend/index.tsx @@ -41,7 +41,6 @@ function ConfirmKeysend() { ((parseInt(amount) || 0) * 10).toString() ); const [fiatAmount, setFiatAmount] = useState(""); - const [fiatBudgetAmount, setFiatBudgetAmount] = useState(""); const [loading, setLoading] = useState(false); const [successMessage, setSuccessMessage] = useState(""); @@ -54,13 +53,6 @@ function ConfirmKeysend() { })(); }, [amount, showFiat, getFormattedFiat]); - useEffect(() => { - (async () => { - const res = await getFormattedFiat(budget); - setFiatBudgetAmount(res); - })(); - }, [budget, showFiat, getFormattedFiat]); - async function confirm() { if (rememberMe && budget) { await saveBudget(); @@ -153,7 +145,7 @@ function ConfirmKeysend() {
{ setRememberMe(event.target.checked); diff --git a/src/app/screens/ConfirmPayment/index.test.tsx b/src/app/screens/ConfirmPayment/index.test.tsx index 8933f4221c..30314360b2 100644 --- a/src/app/screens/ConfirmPayment/index.test.tsx +++ b/src/app/screens/ConfirmPayment/index.test.tsx @@ -44,10 +44,12 @@ jest.mock("~/app/context/SettingsContext", () => ({ useSettings: () => ({ settings: mockSettingsTmp, isLoading: false, + getCurrencyRate: jest.fn(() => 11), updateSetting: jest.fn(), getFormattedFiat: mockGetFiatValue, getFormattedNumber: jest.fn(), getFormattedSats: jest.fn(() => "25 sats"), + getCurrencySymbol: jest.fn(() => "₿"), }), })); diff --git a/src/app/screens/ConfirmPayment/index.tsx b/src/app/screens/ConfirmPayment/index.tsx index 7481fd4dc7..2db1361f1a 100644 --- a/src/app/screens/ConfirmPayment/index.tsx +++ b/src/app/screens/ConfirmPayment/index.tsx @@ -44,7 +44,6 @@ function ConfirmPayment() { ((invoice.satoshis || 0) * 10).toString() ); const [fiatAmount, setFiatAmount] = useState(""); - const [fiatBudgetAmount, setFiatBudgetAmount] = useState(""); const formattedInvoiceSats = getFormattedSats(invoice.satoshis || 0); @@ -57,15 +56,6 @@ function ConfirmPayment() { })(); }, [invoice.satoshis, showFiat, getFormattedFiat]); - useEffect(() => { - (async () => { - if (showFiat && budget) { - const res = await getFormattedFiat(budget); - setFiatBudgetAmount(res); - } - })(); - }, [budget, showFiat, getFormattedFiat]); - const [rememberMe, setRememberMe] = useState(false); const [loading, setLoading] = useState(false); const [successMessage, setSuccessMessage] = useState(""); @@ -160,7 +150,7 @@ function ConfirmPayment() {
{navState.origin && ( { setRememberMe(event.target.checked); diff --git a/src/app/screens/Keysend/index.test.tsx b/src/app/screens/Keysend/index.test.tsx index f5f9534732..573f009624 100644 --- a/src/app/screens/Keysend/index.test.tsx +++ b/src/app/screens/Keysend/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, act } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; import { SettingsProvider } from "~/app/context/SettingsContext"; @@ -42,7 +42,7 @@ jest.mock("~/common/lib/api", () => { return { ...original, getSettings: jest.fn(() => Promise.resolve(mockSettings)), - getCurrencyRate: jest.fn(() => Promise.resolve({ rate: 11 })), + getCurrencyRate: jest.fn(() => 11), }; }); @@ -59,6 +59,6 @@ describe("Keysend", () => { }); expect(await screen.findByText("Send payment to")).toBeInTheDocument(); - expect(await screen.getByLabelText("Amount (Satoshi)")).toHaveValue(21); + expect(await screen.getByLabelText("Amount")).toHaveValue(21); }); }); diff --git a/src/app/screens/Keysend/index.tsx b/src/app/screens/Keysend/index.tsx index d581e0359a..2a045edd4b 100644 --- a/src/app/screens/Keysend/index.tsx +++ b/src/app/screens/Keysend/index.tsx @@ -5,9 +5,11 @@ import Header from "@components/Header"; import IconButton from "@components/IconButton"; import ResultCard from "@components/ResultCard"; import SatButtons from "@components/SatButtons"; -import DualCurrencyField from "@components/form/DualCurrencyField"; +import DualCurrencyField, { + DualCurrencyFieldChangeEvent, +} from "@components/form/DualCurrencyField"; import { PopiconsChevronLeftLine } from "@popicons/react"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Container from "~/app/components/Container"; @@ -21,7 +23,6 @@ function Keysend() { const { isLoading: isLoadingSettings, settings, - getFormattedFiat, getFormattedSats, } = useSettings(); const showFiat = !isLoadingSettings && settings.showFiat; @@ -46,15 +47,6 @@ function Keysend() { : +amountSat > (auth?.account?.balance || 0); const rangeExceeded = +amountSat < amountMin; - useEffect(() => { - (async () => { - if (amountSat !== "" && showFiat) { - const res = await getFormattedFiat(amountSat); - setFiatAmount(res); - } - })(); - }, [amountSat, showFiat, getFormattedFiat]); - async function confirm() { try { setLoading(true); @@ -124,11 +116,14 @@ function Keysend() { /> setAmountSat(e.target.value)} value={amountSat} - fiatValue={fiatAmount} + showFiat={showFiat} + onChange={(e: DualCurrencyFieldChangeEvent) => { + setAmountSat(e.target.value); + setFiatAmount(e.target.formattedValueInFiat); + }} hint={`${tCommon("balance")}: ${auth?.balancesDecorated ?.accountBalance}`} amountExceeded={amountExceeded} diff --git a/src/app/screens/LNURLChannel/index.test.tsx b/src/app/screens/LNURLChannel/index.test.tsx index f296a43c5b..9c2642ed45 100644 --- a/src/app/screens/LNURLChannel/index.test.tsx +++ b/src/app/screens/LNURLChannel/index.test.tsx @@ -11,7 +11,7 @@ jest.mock("~/common/lib/api", () => { return { ...original, getSettings: jest.fn(() => Promise.resolve(mockSettings)), - getCurrencyRate: jest.fn(() => Promise.resolve({ rate: 11 })), + getCurrencyRate: jest.fn(() => 11), }; }); diff --git a/src/app/screens/LNURLPay/index.test.tsx b/src/app/screens/LNURLPay/index.test.tsx index 8a3835d1fe..ba287d1d74 100644 --- a/src/app/screens/LNURLPay/index.test.tsx +++ b/src/app/screens/LNURLPay/index.test.tsx @@ -1,11 +1,11 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; import type { LNURLDetails, OriginData } from "~/types"; import LNURLPay from "./index"; -const mockGetFiatValue = jest.fn(() => Promise.resolve("$1,22")); +const mockGetFiatValue = jest.fn(() => "$1,22"); jest.mock("~/app/context/SettingsContext", () => ({ useSettings: () => ({ @@ -15,6 +15,9 @@ jest.mock("~/app/context/SettingsContext", () => ({ getFormattedFiat: mockGetFiatValue, getFormattedNumber: jest.fn(), getFormattedSats: jest.fn(), + getCurrencyRate: jest.fn(() => 1), + getCurrencySymbol: jest.fn(() => "₿"), + getFormattedInCurrency: jest.fn(), }), })); @@ -93,12 +96,6 @@ describe("LNURLPay", () => { ); - // get fiat on mount - await waitFor(() => - expect(mockGetFiatValue).toHaveBeenCalledWith(satValue.toString()) - ); - await waitFor(() => expect(mockGetFiatValue).toHaveBeenCalledTimes(1)); - expect(await screen.getByText("blocktime 748949")).toBeInTheDocument(); expect(await screen.getByText("16sat/vB & empty")).toBeInTheDocument(); expect(await screen.getByLabelText("Amount")).toHaveValue(satValue); diff --git a/src/app/screens/LNURLPay/index.tsx b/src/app/screens/LNURLPay/index.tsx index 27b212a53a..e10ae1f551 100644 --- a/src/app/screens/LNURLPay/index.tsx +++ b/src/app/screens/LNURLPay/index.tsx @@ -5,7 +5,9 @@ import Hyperlink from "@components/Hyperlink"; import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; import SatButtons from "@components/SatButtons"; -import DualCurrencyField from "@components/form/DualCurrencyField"; +import DualCurrencyField, { + DualCurrencyFieldChangeEvent, +} from "@components/form/DualCurrencyField"; import TextField from "@components/form/TextField"; import { PopiconsChevronBottomLine, @@ -35,7 +37,6 @@ import type { LNURLPaymentSuccessAction, PaymentResponse, } from "~/types"; - const Dt = ({ children }: { children: React.ReactNode }) => (
{children}
); @@ -53,7 +54,6 @@ function LNURLPay() { const { isLoading: isLoadingSettings, settings, - getFormattedFiat, getFormattedSats, } = useSettings(); const showFiat = !isLoadingSettings && settings.showFiat; @@ -87,15 +87,6 @@ function LNURLPay() { LNURLPaymentSuccessAction | undefined >(); - useEffect(() => { - const getFiat = async () => { - const res = await getFormattedFiat(valueSat); - setFiatValue(res); - }; - - getFiat(); - }, [valueSat, showFiat, getFormattedFiat]); - useEffect(() => { !!settings.userName && setUserName(settings.userName); !!settings.userEmail && setUserEmail(settings.userEmail); @@ -451,8 +442,11 @@ function LNURLPay() { max={amountMax} rangeExceeded={rangeExceeded} value={valueSat} - onChange={(e) => setValueSat(e.target.value)} - fiatValue={fiatValue} + onChange={(e: DualCurrencyFieldChangeEvent) => { + setValueSat(e.target.value); + setFiatValue(e.target.formattedValueInFiat); + }} + showFiat={showFiat} hint={`${tCommon("balance")}: ${auth ?.balancesDecorated?.accountBalance}`} amountExceeded={amountExceeded} diff --git a/src/app/screens/LNURLWithdraw/index.tsx b/src/app/screens/LNURLWithdraw/index.tsx index 3deebfb9ab..e26d9c7863 100644 --- a/src/app/screens/LNURLWithdraw/index.tsx +++ b/src/app/screens/LNURLWithdraw/index.tsx @@ -4,9 +4,11 @@ import Container from "@components/Container"; import ContentMessage from "@components/ContentMessage"; import PublisherCard from "@components/PublisherCard"; import ResultCard from "@components/ResultCard"; -import DualCurrencyField from "@components/form/DualCurrencyField"; +import DualCurrencyField, { + DualCurrencyFieldChangeEvent, +} from "@components/form/DualCurrencyField"; import axios from "axios"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import ScreenHeader from "~/app/components/ScreenHeader"; @@ -17,7 +19,6 @@ import { USER_REJECTED_ERROR } from "~/common/constants"; import api from "~/common/lib/api"; import msg from "~/common/lib/msg"; import type { LNURLWithdrawServiceResponse } from "~/types"; - function LNURLWithdraw() { const { t } = useTranslation("translation", { keyPrefix: "lnurlwithdraw" }); const { t: tCommon } = useTranslation("common"); @@ -27,7 +28,6 @@ function LNURLWithdraw() { const { isLoading: isLoadingSettings, settings, - getFormattedFiat, getFormattedSats, } = useSettings(); const showFiat = !isLoadingSettings && settings.showFiat; @@ -43,15 +43,6 @@ function LNURLWithdraw() { const [successMessage, setSuccessMessage] = useState(""); const [fiatValue, setFiatValue] = useState(""); - useEffect(() => { - if (valueSat !== "" && showFiat) { - (async () => { - const res = await getFormattedFiat(valueSat); - setFiatValue(res); - })(); - } - }, [valueSat, showFiat, getFormattedFiat]); - async function confirm() { try { setLoadingConfirm(true); @@ -117,8 +108,11 @@ function LNURLWithdraw() { min={Math.floor(minWithdrawable / 1000)} max={Math.floor(maxWithdrawable / 1000)} value={valueSat} - onChange={(e) => setValueSat(e.target.value)} - fiatValue={fiatValue} + onChange={(e: DualCurrencyFieldChangeEvent) => { + setValueSat(e.target.value); + setFiatValue(e.target.formattedValueInFiat); + }} + showFiat={showFiat} />
); diff --git a/src/app/screens/MakeInvoice/index.test.tsx b/src/app/screens/MakeInvoice/index.test.tsx index 1d6b667710..5dcde46ad0 100644 --- a/src/app/screens/MakeInvoice/index.test.tsx +++ b/src/app/screens/MakeInvoice/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, act } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings"; import type { OriginData } from "~/types"; @@ -48,6 +48,9 @@ jest.mock("~/app/context/SettingsContext", () => ({ getFormattedFiat: jest.fn(() => Promise.resolve("$0.01")), getFormattedNumber: jest.fn(), getFormattedSats: jest.fn(), + getCurrencyRate: jest.fn(() => 1), + getCurrencySymbol: jest.fn(() => "₿"), + getFormattedInCurrency: jest.fn(() => "$0.01"), }), })); @@ -61,7 +64,7 @@ describe("MakeInvoice", () => { ); }); - expect(await screen.findByLabelText("Amount (Satoshi)")).toHaveValue(21); + expect(await screen.findByLabelText("Amount")).toHaveValue(21); expect(await screen.findByLabelText("Memo")).toHaveValue("Test memo"); expect(screen.getByText(/~\$0.01/)).toBeInTheDocument(); }); diff --git a/src/app/screens/MakeInvoice/index.tsx b/src/app/screens/MakeInvoice/index.tsx index cf0062e7ad..b8b5d1905c 100644 --- a/src/app/screens/MakeInvoice/index.tsx +++ b/src/app/screens/MakeInvoice/index.tsx @@ -4,7 +4,7 @@ import PublisherCard from "@components/PublisherCard"; import SatButtons from "@components/SatButtons"; import DualCurrencyField from "@components/form/DualCurrencyField"; import TextField from "@components/form/TextField"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import ScreenHeader from "~/app/components/ScreenHeader"; import toast from "~/app/components/Toast"; @@ -25,11 +25,7 @@ const Dd = ({ children }: { children: React.ReactNode }) => ( function MakeInvoice() { const navState = useNavigationState(); - const { - isLoading: isLoadingSettings, - settings, - getFormattedFiat, - } = useSettings(); + const { isLoading: isLoadingSettings, settings } = useSettings(); const showFiat = !isLoadingSettings && settings.showFiat; const origin = navState.origin as OriginData; @@ -39,7 +35,6 @@ function MakeInvoice() { const memoEditable = navState.args?.memoEditable; const [loading, setLoading] = useState(false); const [valueSat, setValueSat] = useState(invoiceAttributes.amount || ""); - const [fiatValue, setFiatValue] = useState(""); const [memo, setMemo] = useState(invoiceAttributes.memo || ""); const [error, setError] = useState(""); const { t: tCommon } = useTranslation("common"); @@ -47,15 +42,6 @@ function MakeInvoice() { keyPrefix: "make_invoice", }); - useEffect(() => { - if (valueSat !== "" && showFiat) { - (async () => { - const res = await getFormattedFiat(valueSat); - setFiatValue(res); - })(); - } - }, [valueSat, showFiat, getFormattedFiat]); - function handleValueChange(amount: string) { setError(""); if ( @@ -127,12 +113,12 @@ function MakeInvoice() {
handleValueChange(e.target.value)} - fiatValue={fiatValue} + showFiat={showFiat} />
diff --git a/src/app/screens/ReceiveInvoice/index.tsx b/src/app/screens/ReceiveInvoice/index.tsx index ace65f36d4..dc59ec9686 100644 --- a/src/app/screens/ReceiveInvoice/index.tsx +++ b/src/app/screens/ReceiveInvoice/index.tsx @@ -27,11 +27,7 @@ function ReceiveInvoice() { const { t: tCommon } = useTranslation("common"); const auth = useAccount(); - const { - isLoading: isLoadingSettings, - settings, - getFormattedFiat, - } = useSettings(); + const { isLoading: isLoadingSettings, settings } = useSettings(); const showFiat = !isLoadingSettings && settings.showFiat; const navigate = useNavigate(); @@ -60,17 +56,6 @@ function ReceiveInvoice() { }; }, []); - const [fiatAmount, setFiatAmount] = useState(""); - - useEffect(() => { - if (formData.amount !== "" && showFiat) { - (async () => { - const res = await getFormattedFiat(formData.amount); - setFiatAmount(res); - })(); - } - }, [formData, showFiat, getFormattedFiat]); - function handleChange( event: React.ChangeEvent ) { @@ -273,8 +258,7 @@ function ReceiveInvoice() { id="amount" min={0} label={t("amount.label")} - placeholder={t("amount.placeholder")} - fiatValue={fiatAmount} + showFiat={showFiat} onChange={handleChange} autoFocus /> diff --git a/src/app/screens/SendToBitcoinAddress/index.tsx b/src/app/screens/SendToBitcoinAddress/index.tsx index b363d39bf3..7dc46234cb 100644 --- a/src/app/screens/SendToBitcoinAddress/index.tsx +++ b/src/app/screens/SendToBitcoinAddress/index.tsx @@ -1,11 +1,15 @@ -import { PopiconsLinkExternalSolid } from "@popicons/react"; import Button from "@components/Button"; import ConfirmOrCancel from "@components/ConfirmOrCancel"; import Header from "@components/Header"; import IconButton from "@components/IconButton"; -import DualCurrencyField from "@components/form/DualCurrencyField"; +import DualCurrencyField, { + DualCurrencyFieldChangeEvent, +} from "@components/form/DualCurrencyField"; import { CreateSwapResponse } from "@getalby/sdk/dist/types"; -import { PopiconsChevronLeftLine } from "@popicons/react"; +import { + PopiconsChevronLeftLine, + PopiconsLinkExternalSolid, +} from "@popicons/react"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Skeleton from "react-loading-skeleton"; @@ -65,15 +69,6 @@ function SendToBitcoinAddress() { }); const { t: tCommon } = useTranslation("common"); - useEffect(() => { - (async () => { - if (amountSat !== "" && showFiat) { - const res = await getFormattedFiat(amountSat); - setFiatAmount(res); - } - })(); - }, [amountSat, showFiat, getFormattedFiat]); - useEffect(() => { (async () => { try { @@ -255,9 +250,12 @@ function SendToBitcoinAddress() { label={tCommon("amount")} min={amountMin} max={amountMax} - onChange={(e) => setAmountSat(e.target.value)} + onChange={(e: DualCurrencyFieldChangeEvent) => { + setAmountSat(e.target.value); + setFiatAmount(e.target.formattedValueInFiat); + }} + showFiat={showFiat} value={amountSat} - fiatValue={fiatAmount} rangeExceeded={rangeExceeded} amountExceeded={amountExceeded} hint={`${tCommon("balance")}: ${auth?.balancesDecorated diff --git a/src/common/utils/currencyConvert.ts b/src/common/utils/currencyConvert.ts index 2a83b657d5..b792081002 100644 --- a/src/common/utils/currencyConvert.ts +++ b/src/common/utils/currencyConvert.ts @@ -25,6 +25,22 @@ export const getFormattedCurrency = (params: { }).format(Number(params.amount)); }; +export const getCurrencySymbol = (params: { + currency: CURRENCIES | ACCOUNT_CURRENCIES; + locale: string; +}) => { + if (params.currency === "BTC") return "₿"; + const l = (params.locale || "en").toLowerCase().replace("_", "-"); + const value = + new Intl.NumberFormat(l || "en", { + style: "currency", + currency: params.currency, + }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value || ""; + return value; +}; + export const getFormattedFiat = (params: { amount: number | string; rate: number; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 49a9195a70..d26c526229 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1123,6 +1123,7 @@ "description": "Description", "description_full": "Full Description", "success_message": "{{amount}}{{fiatAmount}} are on their way to {{destination}}", + "amount_placeholder": "Amount in {{currency}}...", "response": "Response", "message": "Message", "help": "Alby Guides",