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

@@ -0,0 +1,188 @@
import React, { useEffect, useId, useMemo, useState } from "react";
import { Box, Text } from "ink";
import { ScrollableList, type ListItemData } from "./List.js";
import TextInput from "./TextInput.js";
import { DialogWrapper } from "./Dialog.js";
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
import { colors } from "../theme.js";
/**
* Props for the currency selection dialog.
*/
interface CurrencySelectionDialogProps {
/** Current wallet currency from persisted settings. */
currentCurrency: string;
/** Available fiat numerator symbols that can be paired with BCH. */
currencies: string[];
/** True while the dialog is loading available pairs. */
isLoading: boolean;
/** Optional loading/error message for pair discovery. */
errorMessage: string | null;
/** Called when the user chooses a currency and confirms. */
onSelectCurrency: (currencyCode: string) => void;
/** Called when the dialog should close without applying changes. */
onCancel: () => void;
}
/**
* Currency picker dialog.
*
* UX requirements:
* - Arrow keys move the highlighted item.
* - Typing immediately filters results.
* - Enter applies current selection.
* - Escape closes without saving.
*/
export function CurrencySelectionDialog({
currentCurrency,
currencies,
isLoading,
errorMessage,
onSelectCurrency,
onCancel,
}: CurrencySelectionDialogProps): React.ReactElement {
const layerId = useId();
const [filterText, setFilterText] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
// Mount this as a capturing input layer so background screens stop handling keys.
useInputLayer(layerId);
/**
* Applies the currently selected filtered result.
*/
const applySelection = (): void => {
const selectedCurrency = filteredCurrencies[selectedIndex];
if (!selectedCurrency) {
return;
}
onSelectCurrency(selectedCurrency);
};
useLayeredInput(layerId, (_input, key) => {
if (key.escape) {
onCancel();
return;
}
if (key.upArrow) {
setSelectedIndex((prev) =>
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
);
return;
}
if (key.downArrow) {
setSelectedIndex((prev) =>
filteredCurrencies.length === 0
? 0
: prev >= filteredCurrencies.length - 1
? 0
: prev + 1,
);
return;
}
});
/**
* Filter currencies as the user types.
*/
const filteredCurrencies = useMemo(() => {
const normalizedFilter = filterText.trim().toUpperCase();
if (!normalizedFilter) {
return currencies;
}
return currencies.filter((currencyCode) =>
currencyCode.toUpperCase().includes(normalizedFilter),
);
}, [currencies, filterText]);
/**
* Keep selected index valid whenever filtering shrinks the result set.
*/
useEffect(() => {
if (filteredCurrencies.length === 0) {
setSelectedIndex(0);
return;
}
if (selectedIndex >= filteredCurrencies.length) {
setSelectedIndex(filteredCurrencies.length - 1);
}
}, [filteredCurrencies, selectedIndex]);
/**
* When the dialog opens or the list updates, default to current currency.
*/
useEffect(() => {
if (filterText.trim().length > 0) {
return;
}
const currentIndex = filteredCurrencies.findIndex(
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
);
if (currentIndex >= 0) {
setSelectedIndex(currentIndex);
}
}, [filteredCurrencies, currentCurrency, filterText]);
const listItems: ListItemData<string>[] = filteredCurrencies.map(
(currencyCode) => ({
key: currencyCode,
label: currencyCode,
description:
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
? "(current)"
: undefined,
value: currencyCode,
}),
);
return (
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
<Text color={colors.textMuted}>
Available BCH quote pairs are loaded from the live rates adapter.
</Text>
<Box marginTop={1}>
<Text color={colors.primary}>Filter:</Text>
</Box>
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
<TextInput
value={filterText}
onChange={setFilterText}
onSubmit={() => applySelection()}
placeholder="Type currency code (e.g. USD, AUD)..."
focus
/>
</Box>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
<Text color={colors.textMuted}>Loading available pairs...</Text>
) : errorMessage ? (
<Text color={colors.error}>{errorMessage}</Text>
) : (
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={() => applySelection()}
focus={false}
maxVisible={8}
emptyMessage="No BCH quote pairs match this filter."
/>
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Type to filter navigate Enter apply Esc cancel
</Text>
</Box>
</DialogWrapper>
);
}

View File

@@ -68,7 +68,7 @@ export function VariableInputField({
focusColor,
}: VariableInputFieldProps): React.ReactElement {
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion("USD");
useSatoshisConversion();
const satoshisValue = useMemo(
() => parseSatoshis(variable.value),
[variable.value],

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import { useAppContext } from './useAppContext.js';
import { useBchToFiatRate } from './useRates.js';
@@ -9,9 +9,40 @@ import { useBchToFiatRate } from './useRates.js';
* component using it will re-render automatically when the selected pair
* receives a new quote.
*/
export function useSatoshisConversion(targetCurrency: string = 'USD') {
export function useSatoshisConversion(targetCurrency?: string) {
const { appService } = useAppContext();
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
const subscribeToCurrency = useCallback(
(callback: () => void) => {
if (!appService || targetCurrency) {
return () => {};
}
return appService.settings.on('settings-updated', (event) => {
if (event.key === 'currency') {
callback();
}
});
},
[appService, targetCurrency],
);
const getCurrencySnapshot = useCallback(() => {
if (targetCurrency) {
return targetCurrency.toUpperCase();
}
if (!appService) {
return 'USD';
}
return appService.settings.getCurrency();
}, [appService, targetCurrency]);
const currencyCode = useSyncExternalStore(
subscribeToCurrency,
getCurrencySnapshot,
getCurrencySnapshot,
);
const fiatPerBchRate = useBchToFiatRate(currencyCode);
const formattedFiatPerBchRate = useMemo(() => {

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];