Add currency settings, Settings service, and dialog to select fiat currency. Add support for non Official currencies like DOGE when using rates.

This commit is contained in:
2026-05-11 10:41:41 +00:00
parent ebe1d8acda
commit 6c01ac1c1b
28 changed files with 1102 additions and 48 deletions

View File

@@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js';
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
@@ -60,6 +61,7 @@ const menuItems: ListItemData<string>[] = [
{ key: 'import', label: 'Import Invitation', value: 'import' },
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
];
@@ -121,7 +123,7 @@ export function WalletStateScreen(): React.ReactElement {
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} = useSatoshisConversion('USD');
} = useSatoshisConversion();
// State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -133,6 +135,14 @@ export function WalletStateScreen(): React.ReactElement {
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
const [qrAddress, setQrAddress] = useState<string | null>(null);
/** Whether the fiat currency selection dialog is open. */
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
/** Loading state for rates pair discovery. */
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
/** Optional error message shown in the currency dialog. */
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
/** Available fiat currencies derived from rates pairs in X/BCH format. */
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
/**
* Refreshes wallet state.
@@ -260,6 +270,89 @@ export function WalletStateScreen(): React.ReactElement {
}
}, [appService, setStatus, showError, showInfo, refresh]);
/**
* Loads all available rates pairs, then extracts fiat numerator symbols from
* pairs shaped like X/BCH.
*
* We retry briefly because rates startup is asynchronous and metadata can take
* a moment to hydrate right after wallet initialization.
*/
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
if (!appService) {
setCurrencyPairsError("AppService not initialized");
return;
}
setLoadingCurrencyPairs(true);
setCurrencyPairsError(null);
try {
let pairs = new Set<string>();
// Retry a few times so we can catch late metadata initialization.
for (let attempt = 0; attempt < 4; attempt += 1) {
pairs = await appService.rates.listPairs();
if (pairs.size > 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
const currencies = Array.from(pairs)
.map((pair) => pair.toUpperCase())
.filter((pair) => pair.endsWith("/BCH"))
.map((pair) => pair.split("/")[0] ?? "")
.filter((currency) => currency.length > 0)
.sort((a, b) => a.localeCompare(b));
const uniqueCurrencies = Array.from(new Set(currencies));
setAvailableCurrencies(uniqueCurrencies);
if (uniqueCurrencies.length === 0) {
setCurrencyPairsError(
"No X/BCH rates are currently available. Try again in a moment.",
);
}
} catch (error) {
setCurrencyPairsError(
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setLoadingCurrencyPairs(false);
}
}, [appService]);
/**
* Opens the fiat currency dialog and triggers pair discovery.
*/
const openCurrencyDialog = useCallback(() => {
setCurrencyDialogOpen(true);
void loadAvailableCurrencies();
}, [loadAvailableCurrencies]);
/**
* Applies the selected fiat currency to persisted settings.
*/
const applyCurrencySelection = useCallback(
(currencyCode: string) => {
if (!appService) {
showError("AppService not initialized");
return;
}
try {
appService.settings.setCurrency(currencyCode);
setStatus(`Fiat currency updated to ${currencyCode}`);
setCurrencyDialogOpen(false);
} catch (error) {
showError(
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
[appService, setStatus, showError],
);
/**
* Handles menu action.
*/
@@ -277,6 +370,9 @@ export function WalletStateScreen(): React.ReactElement {
case 'new-address':
generateNewAddress();
break;
case 'set-currency':
openCurrencyDialog();
break;
case 'unreserve-all':
unreserveAll();
break;
@@ -284,7 +380,7 @@ export function WalletStateScreen(): React.ReactElement {
refresh();
break;
}
}, [navigate, generateNewAddress, unreserveAll, refresh]);
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
/**
* Handle menu item activation.
@@ -543,6 +639,27 @@ export function WalletStateScreen(): React.ReactElement {
onClose={() => setQrAddress(null)}
/>
)}
{/* Fiat currency selection dialog overlay */}
{isCurrencyDialogOpen && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<CurrencySelectionDialog
currentCurrency={currencyCode}
currencies={availableCurrencies}
isLoading={isLoadingCurrencyPairs}
errorMessage={currencyPairsError}
onSelectCurrency={applyCurrencySelection}
onCancel={() => setCurrencyDialogOpen(false)}
/>
</Box>
)}
</Box>
);
}

View File

@@ -23,7 +23,7 @@ export function InputsStep({
changeAmount,
focusArea,
}: Props): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const { formatSatoshisToFiat } = useSatoshisConversion();
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);

View File

@@ -23,7 +23,7 @@ export function ReviewStep({
changeAmount,
}: ReviewStepProps): React.ReactElement {
const selectedUtxos = availableUtxos.filter((u) => u.selected);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const { formatSatoshisToFiat } = useSatoshisConversion();
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);

View File

@@ -113,7 +113,7 @@ export function InvitationScreen(): React.ReactElement {
const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion('USD');
useSatoshisConversion();
// ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0);

View File

@@ -33,7 +33,7 @@ export function InputsSelectStep({
const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const { formatSatoshisToFiat } = useSatoshisConversion();
const fee = DEFAULT_FEE;

View File

@@ -42,7 +42,7 @@ export function PreviewInvitationStep({
onCancel,
isActive,
}: PreviewStepProps): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const { formatSatoshisToFiat } = useSatoshisConversion();
useLayeredInput('import-flow', (_input, key) => {
if (key.return) onComplete();

View File

@@ -33,7 +33,7 @@ export function ReviewStep({
}: ReviewStepProps): React.ReactElement {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const { formatSatoshisToFiat } = useSatoshisConversion();
const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier];