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:
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
188
src/tui/components/CurrencySelectionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user