feat(gui): Allow to select from recently used monero addresses (#139)

* feat(gui): Allow user to select from recently used monero addresses in textfield
This commit is contained in:
binarybaron 2024-11-09 12:11:00 +01:00 committed by GitHub
parent 4867d2713f
commit bd3fca7e41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 271 additions and 78 deletions

View File

@ -10,6 +10,14 @@ import SwapPage from "./pages/swap/SwapPage";
import WalletPage from "./pages/wallet/WalletPage";
import GlobalSnackbarProvider from "./snackbar/GlobalSnackbarProvider";
import UpdaterDialog from "./modal/updater/UpdaterDialog";
import { initEventListeners } from "renderer/rpc";
import { useEffect } from "react";
import { fetchProvidersViaHttp, fetchAlertsViaHttp, fetchXmrPrice, fetchBtcPrice, fetchXmrBtcRate } from "renderer/api";
import { store } from "renderer/store/storeRenderer";
import { setAlerts } from "store/features/alertsSlice";
import { setRegistryProviders, registryConnectionFailed } from "store/features/providersSlice";
import { setXmrPrice, setBtcPrice, setXmrBtcRate } from "store/features/ratesSlice";
import logger from "utils/logger";
const useStyles = makeStyles((theme) => ({
innerContent: {
@ -52,6 +60,11 @@ function InnerContent() {
}
export default function App() {
useEffect(() => {
fetchInitialData();
initEventListeners();
}, []);
return (
<ThemeProvider theme={theme}>
<GlobalSnackbarProvider>
@ -65,3 +78,46 @@ export default function App() {
</ThemeProvider>
);
}
async function fetchInitialData() {
try {
const providerList = await fetchProvidersViaHttp();
store.dispatch(setRegistryProviders(providerList));
logger.info(
{ providerList },
"Fetched providers via UnstoppableSwap HTTP API",
);
} catch (e) {
store.dispatch(registryConnectionFailed());
logger.error(e, "Failed to fetch providers via UnstoppableSwap HTTP API");
}
try {
const alerts = await fetchAlertsViaHttp();
store.dispatch(setAlerts(alerts));
logger.info({ alerts }, "Fetched alerts via UnstoppableSwap HTTP API");
} catch (e) {
logger.error(e, "Failed to fetch alerts via UnstoppableSwap HTTP API");
}
try {
const xmrPrice = await fetchXmrPrice();
store.dispatch(setXmrPrice(xmrPrice));
logger.info({ xmrPrice }, "Fetched XMR price");
const btcPrice = await fetchBtcPrice();
store.dispatch(setBtcPrice(btcPrice));
logger.info({ btcPrice }, "Fetched BTC price");
} catch (e) {
logger.error(e, "Error retrieving fiat prices");
}
try {
const xmrBtcRate = await fetchXmrBtcRate();
store.dispatch(setXmrBtcRate(xmrBtcRate));
logger.info({ xmrBtcRate }, "Fetched XMR/BTC rate");
} catch (e) {
logger.error(e, "Error retrieving XMR/BTC rate");
}
}

View File

@ -1,8 +1,18 @@
import { TextField } from "@material-ui/core";
import { Box, Button, Dialog, DialogActions, DialogContent, IconButton, List, ListItem, ListItemText, TextField } from "@material-ui/core";
import { TextFieldProps } from "@material-ui/core/TextField/TextField";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { getMoneroAddresses } from "renderer/rpc";
import { isTestnet } from "store/config";
import { isXmrAddressValid } from "utils/conversionUtils";
import ImportContactsIcon from '@material-ui/icons/ImportContacts';
import TruncatedText from "../other/TruncatedText";
type MoneroAddressTextFieldProps = TextFieldProps & {
address: string;
onAddressChange: (address: string) => void;
onAddressValidityChange: (valid: boolean) => void;
helperText: string;
}
export default function MoneroAddressTextField({
address,
@ -10,30 +20,116 @@ export default function MoneroAddressTextField({
onAddressValidityChange,
helperText,
...props
}: {
address: string;
onAddressChange: (address: string) => void;
onAddressValidityChange: (valid: boolean) => void;
helperText: string;
} & TextFieldProps) {
}: MoneroAddressTextFieldProps) {
const [addresses, setAddresses] = useState<string[]>([]);
const [showDialog, setShowDialog] = useState(false);
// Validation
const placeholder = isTestnet() ? "59McWTPGc745..." : "888tNkZrPN6J...";
const errorText = isXmrAddressValid(address, isTestnet())
? null
: "Not a valid Monero address";
// Effects
useEffect(() => {
onAddressValidityChange(!errorText);
}, [address, onAddressValidityChange, errorText]);
useEffect(() => {
const fetchAddresses = async () => {
const response = await getMoneroAddresses();
setAddresses(response.addresses);
};
fetchAddresses();
}, []);
// Event handlers
const handleClose = () => setShowDialog(false);
const handleAddressSelect = (selectedAddress: string) => {
onAddressChange(selectedAddress);
handleClose();
};
return (
<TextField
value={address}
onChange={(e) => onAddressChange(e.target.value)}
error={!!errorText && address.length > 0}
helperText={address.length > 0 ? errorText || helperText : helperText}
placeholder={placeholder}
variant="outlined"
{...props}
/>
<Box>
<TextField
value={address}
onChange={(e) => onAddressChange(e.target.value)}
error={!!errorText && address.length > 0}
helperText={address.length > 0 ? errorText || helperText : helperText}
placeholder={placeholder}
variant="outlined"
InputProps={{
endAdornment: addresses?.length > 0 && (
<IconButton onClick={() => setShowDialog(true)} size="small">
<ImportContactsIcon />
</IconButton>
)
}}
{...props}
/>
<RecentlyUsedAddressesDialog
open={showDialog}
onClose={handleClose}
addresses={addresses}
onAddressSelect={handleAddressSelect}
/>
</Box>
);
}
interface RecentlyUsedAddressesDialogProps {
open: boolean;
onClose: () => void;
addresses: string[];
onAddressSelect: (address: string) => void;
}
function RecentlyUsedAddressesDialog({
open,
onClose,
addresses,
onAddressSelect
}: RecentlyUsedAddressesDialogProps) {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
>
<DialogContent>
<List>
{addresses.map((addr) => (
<ListItem
button
key={addr}
onClick={() => onAddressSelect(addr)}
>
<ListItemText
primary={
<Box fontFamily="monospace">
<TruncatedText limit={40} truncateMiddle>
{addr}
</TruncatedText>
</Box>
}
secondary="Recently used as a redeem address"
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
variant="contained"
color="primary"
>
Close
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -2,13 +2,20 @@ export default function TruncatedText({
children,
limit = 6,
ellipsis = "...",
truncateMiddle = false,
}: {
children: string;
limit?: number;
ellipsis?: string;
truncateMiddle?: boolean;
}) {
const truncatedText =
children.length > limit ? children.slice(0, limit) + ellipsis : children;
const truncatedText = children.length > limit
? truncateMiddle
? children.slice(0, Math.floor(limit/2)) +
ellipsis +
children.slice(children.length - Math.floor(limit/2))
: children.slice(0, limit) + ellipsis
: children;
return truncatedText;
return <span>{truncatedText}</span>;
}

View File

@ -28,50 +28,4 @@ root.render(
<App />
</PersistGate>
</Provider>,
);
async function fetchInitialData() {
try {
const providerList = await fetchProvidersViaHttp();
store.dispatch(setRegistryProviders(providerList));
logger.info(
{ providerList },
"Fetched providers via UnstoppableSwap HTTP API",
);
} catch (e) {
store.dispatch(registryConnectionFailed());
logger.error(e, "Failed to fetch providers via UnstoppableSwap HTTP API");
}
try {
const alerts = await fetchAlertsViaHttp();
store.dispatch(setAlerts(alerts));
logger.info({ alerts }, "Fetched alerts via UnstoppableSwap HTTP API");
} catch (e) {
logger.error(e, "Failed to fetch alerts via UnstoppableSwap HTTP API");
}
try {
const xmrPrice = await fetchXmrPrice();
store.dispatch(setXmrPrice(xmrPrice));
logger.info({ xmrPrice }, "Fetched XMR price");
const btcPrice = await fetchBtcPrice();
store.dispatch(setBtcPrice(btcPrice));
logger.info({ btcPrice }, "Fetched BTC price");
} catch (e) {
logger.error(e, "Error retrieving fiat prices");
}
try {
const xmrBtcRate = await fetchXmrBtcRate();
store.dispatch(setXmrBtcRate(xmrBtcRate));
logger.info({ xmrBtcRate }, "Fetched XMR/BTC rate");
} catch (e) {
logger.error(e, "Error retrieving XMR/BTC rate");
}
}
fetchInitialData();
initEventListeners();
);

View File

@ -22,6 +22,7 @@ import {
TauriTimelockChangeEvent,
GetSwapInfoArgs,
ExportBitcoinWalletResponse,
GetMoneroAddressesResponse,
} from "models/tauriModel";
import {
contextStatusEventReceived,
@ -218,4 +219,8 @@ export async function initializeContext() {
export async function getWalletDescriptor() {
return await invokeNoArgs<ExportBitcoinWalletResponse>("get_wallet_descriptor");
}
export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse> {
return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses");
}

View File

@ -5,8 +5,9 @@ use swap::cli::{
api::{
request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ExportBitcoinWalletArgs, GetHistoryArgs,
GetLogsArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs,
ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs,
ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs,
WithdrawBtcArgs,
},
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder,
@ -146,6 +147,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
get_balance,
get_monero_addresses,
get_swap_info,
get_swap_infos_all,
withdraw_btc,
@ -200,12 +202,14 @@ tauri_command!(monero_recovery, MoneroRecoveryArgs);
tauri_command!(get_logs, GetLogsArgs);
tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
// These commands require no arguments
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args);
tauri_command!(get_swap_info, GetSwapInfoArgs);
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
tauri_command!(get_history, GetHistoryArgs, no_args);
tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args);
/// Here we define Tauri commands whose implementation is not delegated to the Request trait
#[tauri::command]

View File

@ -9,7 +9,9 @@
"type_info": "Text"
}
],
"nullable": [false],
"nullable": [
false
],
"parameters": {
"Right": 1
}
@ -35,7 +37,9 @@
"type_info": "Text"
}
],
"nullable": [true],
"nullable": [
true
],
"parameters": {
"Right": 1
}
@ -56,7 +60,10 @@
"type_info": "Text"
}
],
"nullable": [false, false],
"nullable": [
false,
false
],
"parameters": {
"Right": 0
}
@ -92,13 +99,33 @@
"type_info": "Text"
}
],
"nullable": [false],
"nullable": [
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT state\n FROM swap_states\n WHERE swap_id = ?\n ORDER BY id desc\n LIMIT 1;\n\n "
},
"98a8b7f4971e0eb4ab8f5aa688aa22e7fdc6b925de211f7784782f051c2dcd8c": {
"describe": {
"columns": [
{
"name": "address",
"ordinal": 0,
"type_info": "Text"
}
],
"nullable": [
false
],
"parameters": {
"Right": 0
}
},
"query": "SELECT DISTINCT address FROM monero_addresses"
},
"b703032b4ddc627a1124817477e7a8e5014bdc694c36a14053ef3bb2fc0c69b0": {
"describe": {
"columns": [],
@ -118,7 +145,9 @@
"type_info": "Text"
}
],
"nullable": [false],
"nullable": [
false
],
"parameters": {
"Right": 1
}
@ -134,7 +163,9 @@
"type_info": "Text"
}
],
"nullable": [false],
"nullable": [
false
],
"parameters": {
"Right": 1
}
@ -150,7 +181,9 @@
"type_info": "Text"
}
],
"nullable": [false],
"nullable": [
false
],
"parameters": {
"Right": 1
}
@ -176,11 +209,13 @@
"type_info": "Text"
}
],
"nullable": [false],
"nullable": [
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT proof\n FROM buffered_transfer_proofs\n WHERE swap_id = ?\n "
}
}
}

View File

@ -416,6 +416,26 @@ impl Request for GetLogsArgs {
}
}
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroAddressesArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct GetMoneroAddressesResponse {
#[typeshare(serialized_as = "Vec<String>")]
pub addresses: Vec<monero::Address>,
}
impl Request for GetMoneroAddressesArgs {
type Response = GetMoneroAddressesResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let addresses = ctx.db.get_monero_addresses().await?;
Ok(GetMoneroAddressesResponse { addresses })
}
}
#[tracing::instrument(fields(method = "suspend_current_swap"), skip(context))]
pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurrentSwapResponse> {
let swap_id = context.swap_lock.get_current_swap_id().await;

View File

@ -143,6 +143,21 @@ impl Database for SqliteDatabase {
Ok(address)
}
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>> {
let mut conn = self.pool.acquire().await?;
let rows = sqlx::query!("SELECT DISTINCT address FROM monero_addresses")
.fetch_all(&mut conn)
.await?;
let addresses = rows
.iter()
.map(|row| row.address.parse())
.collect::<Result<Vec<_>, _>>()?;
Ok(addresses)
}
async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()> {
let mut conn = self.pool.acquire().await?;

View File

@ -137,6 +137,7 @@ pub trait Database {
async fn get_peer_id(&self, swap_id: Uuid) -> Result<PeerId>;
async fn insert_monero_address(&self, swap_id: Uuid, address: monero::Address) -> Result<()>;
async fn get_monero_address(&self, swap_id: Uuid) -> Result<monero::Address>;
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>>;
async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()>;
async fn get_addresses(&self, peer_id: PeerId) -> Result<Vec<Multiaddr>>;
async fn get_swap_start_date(&self, swap_id: Uuid) -> Result<String>;

Binary file not shown.