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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user