mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-11-28 11:40:42 -05:00
feat(gui): Monero wallet (#442)
* feat(gui): Monero wallet
* progress
* refactor
* progress, dont delete wallet, re-fetch approvals and background periodically
* show transaction history correctly
* Enable fetching tx hashes
* Try add the wallet listener event callbacks, not working
* fix: Redeem XMR to internal main wallet, not temp wallet
* type safety
* refactoring of callback system
* make free floating functions generic
* refactor: Format files
* refactor(gui): Split wallet components and redesign balanceOverview component
* refactor(gui): Add action buttons and transaction section
* wrapper event listener
* progress, compiles
* works!
* WORKS! Event received on balance change
* refactor: format and slight refactorings and comments
* refactor(gui): Start with implementation of send dialog
- new number input
- new button variant and size
* add @tauri-apps/plugin-dialog
* feat(gui): Add permissions for file dialog
* fix(monero-harness): Compile issue
* feat(gui): Extract seed from Monero wallet and use for derivation, allow opening existing wallet file
* feat(gui): Always refresh the approval list from frontend when resolving
* fix(monero-rpc-pool): Implement Into<String> for ServerInfo
* fix(monero-sys): Use oneshot channel for all wallets
* feat(gui, monero-sys): Display recently opened wallets
* small refactors
* fix(gui): Enable background_sync, display temp "Loading..." if values are null
* feat(gui): Remove headers from pages, show selected navigation item
* feat(gui): Explicitly tell user if no swaps have been made yet
* feat(gui): send sync and history updates
* feat(gui): Fetch monero wallet details when context becomes availiable
* feat(gui): Display Monero primary address without modal
* feat(gui): Make "swap" button on wallet page take you to "/swap"
* feat(gui): Rework send modal, adjust number input, added send to field
* feat(gui): set block restore height, not working
* refactor(gui): Optimize number input and add support for switching between currency
* feat(gui): Display real fiat currency prices in send modal
* feat(gui): Add error message for too high send amount
* feat(gui): Modern UI for SeedSelectionDialog
* feat(gui): Wrap MoneroWalletActions
* wip
* refactoring approval callback
* feat(gui): Send Direction of Transaction in History to Frontend
* feat(gui): Let user approve transaction before publishing
* feat: Display 8 digits for Monero amounts by default
* feat(monero-sys): Store pending (non published) transactions in Mutex map inside wallet thread
This allows seperating signing and publishing transactions cleanly
* dprint fmt
* fix(gui): Refresh Monero wallet history C++ struct before serializing
* feat(monero-rpc-pool): Fail after three JSON-RPC errors
* feat(monero-sys): Add wrapper around verify_wallet_password
* feat(gui): Allow opening password-protected Wallets
* refactor: fmt, remove receive button
* fix(gui): Convert to XMR before converting into Fiat
* feat(gui): Add dialog for setting restore height
* feat(gui): block height can be changed, blocks when too low
* refactor(monero-sys): Remove old WalletListener code
* feat(gui): Continually ask for user to select wallet and enter password, if user rejects, offer to select different wallet
* refactor(swap): Extract "select Monero wallet" into own function
* refactor(tauri): Dont kill monero-wallet-rpc
* refactor(tauri): Avoid multiple concurrent Contexts starting
* refactor: Change "Cancel" to "Change wallet" on PasswordEntryDialog
* feat(gui): show curent block height, fix blockage
* Cargo.lock update
* refactor(monero-sys): Use match instead of is_err() and expect(...)
* refactor: better context for WalletHandle constructor method errors handling
* refactor(monero-sys): Common open_with<F>(path: String, daemon: Daemon, wallet_op: F) function
* feat: check empty password before requeston password for wallet
* feat: Remove "Checking for available remote nodes" from frontend
* feat(gui): Allow sweeping entire Monero balance
* feat(monero-rpc-pool): Keep alive TCP connections, do not record JSON-RPC errors as failure if >=3 nodes failed
If >=3 nodes failed we assume it was an actual issue on our side, not an issue with the node
* refactor(swap): Remove dead code
* add comment to WalletHandleListener::on_refreshed{...}
* feat(gui): show current block height in the field
* refactor: remove unused UserCancelledError;
* refactor: No Arc<Mutex<_>> for Pending TXs map
* refactor: remove redundant } catch (error) {
* feat: add our new crates to `OUR_CRATES` in tracing util
* fix(gui): Add math.ceil to piconero conversion to ensure integer
* fix(gui): Close menu when option is clicked
* review and improve/reduce uses of unsafe, also remove unique_ptr wrapper around TransactionHistory to avoid double free
* fix(gui): Use monero amount from units.tsx
* fix(gui): Use PromiseInvokeButton for simplification for approving of send transaction
* update comment, rename function
* refactor(gui): Fix alignment of amounts
* refactor(gui): Remove sending and refreshing states from wallet
* fix(cli, gui): use old seed flow on no tauri, fix minor issues in gui
* fix: use the new named function
* refactor(gui): Add skeletons for monero wallet when still loading
* refactor(gui): Remove isLoading from wallet slice
* feat(gui): Add success dialog after send transaction was approved
* fix(gui): Floor piconero amount in sendMoneroTransaction
* feat(gui): Allow view on explorer button on send success modal
* feat(backend): save the wallet state on events
* fix(structure): move throttle into its own crate
* fix(log): remove spammy logs
* fix(logs): log folder in confid
* remove "sync progress: " log
* small refactors
* save wallet at most every 60s
* remove useless logs
* underscore unused variables
* feat(gui): Add timestamp of the tx
* feat(gui): Add the legacy wallet init option
* legac ybutton
* Fix(gui, asb): reverse the log config
remove log in bridge.h
cleanup
* use none for .store(..)
* display dot for running swap
---------
Co-authored-by: Maksim Kirillov <maksim.kirillov@staticlabs.de>
Co-authored-by: b-enedict <benedict.seuss@gmail.com>
Co-authored-by: einliterflasche <einliterflasche@pm.me>
This commit is contained in:
parent
eb0dc10489
commit
a7823d7489
118 changed files with 7857 additions and 3456 deletions
948
src-gui/package-lock.json
generated
948
src-gui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,9 +4,9 @@
|
|||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
|
||||
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.ts",
|
||||
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.ts",
|
||||
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
|
||||
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
|
||||
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"dev": "vite",
|
||||
|
|
@ -22,10 +22,12 @@
|
|||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/lab": "^7.0.0-beta.13",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-date-pickers": "^8.8.0",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-cli": "^2.0.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
"@tauri-apps/plugin-updater": "2.7.1",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"boring-avatars": "^1.11.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"jdenticon": "^3.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SelectMakerDetails,
|
||||
TauriBackgroundProgress,
|
||||
TauriSwapProgressEvent,
|
||||
SendMoneroDetails,
|
||||
} from "./tauriModel";
|
||||
|
||||
export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"];
|
||||
|
|
@ -310,10 +311,13 @@ export type PendingSelectMakerApprovalRequest = PendingApprovalRequest & {
|
|||
request: { type: "SelectMaker"; content: SelectMakerDetails };
|
||||
};
|
||||
|
||||
export interface SortableQuoteWithAddress extends QuoteWithAddress {
|
||||
expiration_ts?: number;
|
||||
request_id?: string;
|
||||
}
|
||||
export type PendingSendMoneroApprovalRequest = PendingApprovalRequest & {
|
||||
request: { type: "SendMonero"; content: SendMoneroDetails };
|
||||
};
|
||||
|
||||
export type PendingPasswordApprovalRequest = PendingApprovalRequest & {
|
||||
request: { type: "PasswordRequest"; content: { wallet_path: string } };
|
||||
};
|
||||
|
||||
export function isPendingSelectMakerApprovalEvent(
|
||||
event: ApprovalRequest,
|
||||
|
|
@ -327,6 +331,30 @@ export function isPendingSelectMakerApprovalEvent(
|
|||
return event.request.type === "SelectMaker";
|
||||
}
|
||||
|
||||
export function isPendingSendMoneroApprovalEvent(
|
||||
event: ApprovalRequest,
|
||||
): event is PendingSendMoneroApprovalRequest {
|
||||
// Check if the request is pending
|
||||
if (event.request_status.state !== "Pending") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the request is a SendMonero request
|
||||
return event.request.type === "SendMonero";
|
||||
}
|
||||
|
||||
export function isPendingPasswordApprovalEvent(
|
||||
event: ApprovalRequest,
|
||||
): event is PendingPasswordApprovalRequest {
|
||||
// Check if the request is pending
|
||||
if (event.request_status.state !== "Pending") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the request is a PasswordRequest request
|
||||
return event.request.type === "PasswordRequest";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any funds have been locked yet based on the swap progress event
|
||||
* Returns true for events where funds have been locked
|
||||
|
|
|
|||
|
|
@ -24,9 +24,16 @@ import {
|
|||
listSellersAtRendezvousPoint,
|
||||
refreshApprovals,
|
||||
updateAllNodeStatuses,
|
||||
fetchAndUpdateBackgroundItems,
|
||||
fetchAndUpdateApprovalItems,
|
||||
} from "./rpc";
|
||||
import { store } from "./store/storeRenderer";
|
||||
import { exhaustiveGuard } from "utils/typescriptUtils";
|
||||
import {
|
||||
setBalance,
|
||||
setHistory,
|
||||
setSyncProgress,
|
||||
} from "store/features/walletSlice";
|
||||
|
||||
const TAURI_UNIFIED_EVENT_CHANNEL_NAME = "tauri-unified-event";
|
||||
|
||||
|
|
@ -45,7 +52,7 @@ const UPDATE_RATE_INTERVAL = 5 * 60 * 1_000;
|
|||
// Fetch all conversations every 10 minutes
|
||||
const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000;
|
||||
|
||||
// Fetch pending approvals every 10 seconds
|
||||
// Fetch pending approvals every 2 seconds
|
||||
const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000;
|
||||
|
||||
function setIntervalImmediate(callback: () => void, interval: number): void {
|
||||
|
|
@ -137,6 +144,19 @@ export async function setupBackgroundTasks(): Promise<void> {
|
|||
store.dispatch(poolStatusReceived(eventData));
|
||||
break;
|
||||
|
||||
case "MoneroWalletUpdate":
|
||||
console.log("MoneroWalletUpdate", eventData);
|
||||
if (eventData.type === "BalanceChange") {
|
||||
store.dispatch(setBalance(eventData.content));
|
||||
}
|
||||
if (eventData.type === "HistoryUpdate") {
|
||||
store.dispatch(setHistory(eventData.content));
|
||||
}
|
||||
if (eventData.type === "SyncProgress") {
|
||||
store.dispatch(setSyncProgress(eventData.content));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
exhaustiveGuard(channelName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import { setupBackgroundTasks } from "renderer/background";
|
|||
import "@fontsource/roboto";
|
||||
import FeedbackPage from "./pages/feedback/FeedbackPage";
|
||||
import IntroductionModal from "./modal/introduction/IntroductionModal";
|
||||
import MoneroWalletPage from "./pages/monero/MoneroWalletPage";
|
||||
import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog";
|
||||
|
||||
declare module "@mui/material/styles" {
|
||||
interface Theme {
|
||||
|
|
@ -44,16 +48,19 @@ export default function App() {
|
|||
return (
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={currentTheme}>
|
||||
<CssBaseline />
|
||||
<GlobalSnackbarProvider>
|
||||
<IntroductionModal />
|
||||
<SeedSelectionDialog />
|
||||
<Router>
|
||||
<Navigation />
|
||||
<InnerContent />
|
||||
<UpdaterDialog />
|
||||
</Router>
|
||||
</GlobalSnackbarProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<CssBaseline />
|
||||
<GlobalSnackbarProvider>
|
||||
<IntroductionModal />
|
||||
<SeedSelectionDialog />
|
||||
<PasswordEntryDialog />
|
||||
<Router>
|
||||
<Navigation />
|
||||
<InnerContent />
|
||||
<UpdaterDialog />
|
||||
</Router>
|
||||
</GlobalSnackbarProvider>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
);
|
||||
|
|
@ -70,12 +77,13 @@ function InnerContent() {
|
|||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<MoneroWalletPage />} />
|
||||
<Route path="/monero-wallet" element={<MoneroWalletPage />} />
|
||||
<Route path="/swap" element={<SwapPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/bitcoin-wallet" element={<WalletPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/feedback" element={<FeedbackPage />} />
|
||||
<Route path="/" element={<SwapPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -186,18 +186,11 @@ export default function DaemonStatusAlert() {
|
|||
const contextStatus = useAppSelector((s) => s.rpc.status);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (
|
||||
contextStatus === null ||
|
||||
contextStatus === TauriContextStatusEvent.NotInitialized
|
||||
) {
|
||||
return (
|
||||
<LoadingSpinnerAlert severity="warning">
|
||||
Checking for available remote nodes
|
||||
</LoadingSpinnerAlert>
|
||||
);
|
||||
}
|
||||
|
||||
switch (contextStatus) {
|
||||
case null:
|
||||
return null;
|
||||
case TauriContextStatusEvent.NotInitialized:
|
||||
return null;
|
||||
case TauriContextStatusEvent.Initializing:
|
||||
return null;
|
||||
case TauriContextStatusEvent.Available:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function FundsLeftInWalletAlert() {
|
|||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => navigate("/wallet")}
|
||||
onClick={() => navigate("/bitcoin-wallet")}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React from "react";
|
||||
import { Box, Alert, AlertTitle } from "@mui/material";
|
||||
import {
|
||||
BobStateName,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type MoneroAddressTextFieldProps = TextFieldProps & {
|
|||
address: string;
|
||||
onAddressChange: (address: string) => void;
|
||||
onAddressValidityChange: (valid: boolean) => void;
|
||||
helperText: string;
|
||||
helperText?: string;
|
||||
};
|
||||
|
||||
export default function MoneroAddressTextField({
|
||||
|
|
|
|||
178
src-gui/src/renderer/components/inputs/NumberInput.tsx
Normal file
178
src-gui/src/renderer/components/inputs/NumberInput.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { darken, useTheme } from "@mui/material";
|
||||
|
||||
interface NumberInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: number;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
minWidth?: number;
|
||||
step?: number;
|
||||
largeStep?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "0.00",
|
||||
fontSize = "2em",
|
||||
fontWeight = 600,
|
||||
textAlign = "right",
|
||||
minWidth = 60,
|
||||
step = 0.001,
|
||||
largeStep = 0.1,
|
||||
className,
|
||||
style,
|
||||
}: NumberInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [inputWidth, setInputWidth] = useState(minWidth);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Calculate precision from step value
|
||||
const getDecimalPrecision = (num: number): number => {
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
return str.split(".")[1].length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const [userPrecision, setUserPrecision] = useState(() =>
|
||||
getDecimalPrecision(step),
|
||||
); // Track user's decimal precision
|
||||
const [minPrecision, setMinPrecision] = useState(3);
|
||||
const appliedPrecision =
|
||||
userPrecision > minPrecision ? userPrecision : minPrecision;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Initialize with placeholder if no value provided
|
||||
useEffect(() => {
|
||||
if (
|
||||
(!value || value.trim() === "" || parseFloat(value) === 0) &&
|
||||
!isFocused
|
||||
) {
|
||||
onChange(placeholder);
|
||||
}
|
||||
}, [placeholder, isFocused, value, onChange]);
|
||||
|
||||
// Update precision when step changes
|
||||
useEffect(() => {
|
||||
setUserPrecision(getDecimalPrecision(step));
|
||||
setMinPrecision(getDecimalPrecision(step));
|
||||
}, [step]);
|
||||
|
||||
// Measure text width to size input dynamically
|
||||
useEffect(() => {
|
||||
if (measureRef.current) {
|
||||
const text = value;
|
||||
measureRef.current.textContent = text;
|
||||
const textWidth = measureRef.current.offsetWidth;
|
||||
setInputWidth(Math.max(textWidth + 5, minWidth)); // Add padding and minimum width
|
||||
}
|
||||
}, [value, placeholder, minWidth]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const currentValue = parseFloat(value) || 0;
|
||||
const increment = e.shiftKey ? largeStep : step;
|
||||
const newValue =
|
||||
e.key === "ArrowUp"
|
||||
? currentValue + increment
|
||||
: Math.max(0, currentValue - increment);
|
||||
// Use the user's precision for formatting
|
||||
onChange(newValue.toFixed(appliedPrecision));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
// Allow empty string, numbers, and decimal points
|
||||
if (inputValue === "" || /^\d*\.?\d*$/.test(inputValue)) {
|
||||
onChange(inputValue);
|
||||
|
||||
// Track the user's decimal precision
|
||||
if (inputValue.includes(".")) {
|
||||
const decimalPart = inputValue.split(".")[1];
|
||||
setUserPrecision(decimalPart ? decimalPart.length : 0);
|
||||
} else if (inputValue && !isNaN(parseFloat(inputValue))) {
|
||||
// No decimal point, so precision is 0
|
||||
setUserPrecision(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
// Reset to placeholder if value is zero or empty
|
||||
if (!value || value.trim() === "" || parseFloat(value) === 0) {
|
||||
onChange(placeholder);
|
||||
} else if (!isNaN(parseFloat(value))) {
|
||||
// Format valid numbers on blur using user's precision
|
||||
const formatted = parseFloat(value).toFixed(appliedPrecision);
|
||||
// Remove trailing zeros if precision allows
|
||||
onChange(parseFloat(formatted).toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
// Determine if we should show placeholder styling
|
||||
const isShowingPlaceholder = value === placeholder && !isFocused;
|
||||
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
fontSize,
|
||||
fontWeight,
|
||||
textAlign,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
width: `${inputWidth}px`,
|
||||
minWidth: `${minWidth}px`,
|
||||
fontFamily: "inherit",
|
||||
color: isShowingPlaceholder
|
||||
? darken(theme.palette.text.primary, 0.5)
|
||||
: theme.palette.text.primary,
|
||||
padding: "4px 0",
|
||||
transition: "color 0.2s ease",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Hidden span for measuring text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontFamily: "inherit",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
className={className}
|
||||
style={defaultStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
Button,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
} from "@mui/material";
|
||||
import { Visibility, VisibilityOff } from "@mui/icons-material";
|
||||
import { useState } from "react";
|
||||
import { usePendingPasswordApproval } from "store/hooks";
|
||||
import { rejectApproval, resolveApproval } from "renderer/rpc";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
|
||||
export default function PasswordEntryDialog() {
|
||||
const pendingApprovals = usePendingPasswordApproval();
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
const approval = pendingApprovals[0];
|
||||
|
||||
const accept = async () => {
|
||||
if (!approval) {
|
||||
throw new Error("No approval request found for password entry");
|
||||
}
|
||||
|
||||
try {
|
||||
await resolveApproval<string>(approval.request_id, password);
|
||||
setPassword("");
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError("Invalid password. Please try again.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async () => {
|
||||
if (!approval) {
|
||||
throw new Error("No approval request found for password entry");
|
||||
}
|
||||
|
||||
try {
|
||||
await rejectApproval<string>(approval.request_id, "");
|
||||
setPassword("");
|
||||
setError("");
|
||||
} catch (err) {
|
||||
console.error("Error rejecting password request:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
if (!approval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
BackdropProps={{
|
||||
sx: {
|
||||
backdropFilter: "blur(8px)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Enter Wallet Password</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={showPassword ? "text" : "password"}
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
if (error) setError("");
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
autoFocus
|
||||
margin="normal"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
accept();
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleTogglePasswordVisibility}
|
||||
edge="end"
|
||||
aria-label="toggle password visibility"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={reject} variant="outlined">
|
||||
Change wallet
|
||||
</Button>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={accept}
|
||||
variant="contained"
|
||||
requiresContext={false}
|
||||
>
|
||||
Unlock
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
|
|
@ -10,17 +9,43 @@ import {
|
|||
RadioGroup,
|
||||
TextField,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePendingSeedSelectionApproval } from "store/hooks";
|
||||
import { resolveApproval, checkSeed } from "renderer/rpc";
|
||||
import { SeedChoice } from "models/tauriModel";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
export default function SeedSelectionDialog() {
|
||||
const pendingApprovals = usePendingSeedSelectionApproval();
|
||||
const [selectedOption, setSelectedOption] = useState<string>("RandomSeed");
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
SeedChoice["type"] | undefined
|
||||
>("RandomSeed");
|
||||
const [customSeed, setCustomSeed] = useState<string>("");
|
||||
const [isSeedValid, setIsSeedValid] = useState<boolean>(false);
|
||||
const approval = pendingApprovals[0]; // Handle the first pending approval
|
||||
const [walletPath, setWalletPath] = useState<string>("");
|
||||
|
||||
const approval = pendingApprovals[0];
|
||||
|
||||
// Extract recent wallets from the approval request content
|
||||
const recentWallets =
|
||||
approval?.request?.type === "SeedSelection"
|
||||
? approval.request.content.recent_wallets
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOption === "FromSeed" && customSeed.trim()) {
|
||||
|
|
@ -36,51 +61,193 @@ export default function SeedSelectionDialog() {
|
|||
}
|
||||
}, [customSeed, selectedOption]);
|
||||
|
||||
const handleClose = async (accept: boolean) => {
|
||||
if (!approval) return;
|
||||
|
||||
if (accept) {
|
||||
const seedChoice =
|
||||
selectedOption === "RandomSeed"
|
||||
? { type: "RandomSeed" }
|
||||
: { type: "FromSeed", content: { seed: customSeed } };
|
||||
|
||||
await resolveApproval(approval.request_id, seedChoice);
|
||||
} else {
|
||||
// On reject, just close without approval
|
||||
await resolveApproval(approval.request_id, { type: "RandomSeed" });
|
||||
// Auto-select the first recent wallet if available
|
||||
useEffect(() => {
|
||||
if (recentWallets.length > 0) {
|
||||
setSelectedOption("FromWalletPath");
|
||||
setWalletPath(recentWallets[0]);
|
||||
}
|
||||
}, [recentWallets.length]);
|
||||
|
||||
const selectWalletFile = async () => {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
setWalletPath(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const Legacy = async () => {
|
||||
if (!approval)
|
||||
throw new Error("No approval request found for seed selection");
|
||||
|
||||
await resolveApproval<SeedChoice>(approval.request_id, {
|
||||
type: "Legacy",
|
||||
});
|
||||
};
|
||||
|
||||
const accept = async () => {
|
||||
if (!approval)
|
||||
throw new Error("No approval request found for seed selection");
|
||||
|
||||
const seedChoice: SeedChoice =
|
||||
selectedOption === "RandomSeed"
|
||||
? { type: "RandomSeed" }
|
||||
: selectedOption === "FromSeed"
|
||||
? { type: "FromSeed", content: { seed: customSeed } }
|
||||
: { type: "FromWalletPath", content: { wallet_path: walletPath } };
|
||||
|
||||
await resolveApproval<SeedChoice>(approval.request_id, seedChoice);
|
||||
};
|
||||
|
||||
if (!approval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Monero Wallet</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Choose what seed to use for the wallet.
|
||||
</Typography>
|
||||
// Disable the button if the user is restoring from a seed and the seed is invalid
|
||||
// or if selecting wallet path and no path is selected
|
||||
const isDisabled =
|
||||
selectedOption === "FromSeed"
|
||||
? customSeed.trim().length === 0 || !isSeedValid
|
||||
: selectedOption === "FromWalletPath"
|
||||
? !walletPath
|
||||
: false;
|
||||
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onChange={(e) => setSelectedOption(e.target.value)}
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
sx={{ "& .MuiDialog-paper": { minHeight: "min(32rem, 80vh)" } }}
|
||||
BackdropProps={{
|
||||
sx: {
|
||||
backdropFilter: "blur(8px)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", gap: 2 }}>
|
||||
{/* Open existing wallet option */}
|
||||
<Card
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
border: selectedOption === "FromWalletPath" ? 2 : 1,
|
||||
borderColor:
|
||||
selectedOption === "FromWalletPath"
|
||||
? "primary.main"
|
||||
: "divider",
|
||||
"&:hover": { borderColor: "primary.main" },
|
||||
flex: 1,
|
||||
}}
|
||||
onClick={() => setSelectedOption("FromWalletPath")}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="RandomSeed"
|
||||
control={<Radio />}
|
||||
label="Create a new wallet"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="FromSeed"
|
||||
control={<Radio />}
|
||||
label="Restore wallet from seed"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon sx={{ fontSize: 32, color: "text.secondary" }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
Open wallet file
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create new wallet option */}
|
||||
<Card
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
border: selectedOption === "RandomSeed" ? 2 : 1,
|
||||
borderColor:
|
||||
selectedOption === "RandomSeed" ? "primary.main" : "divider",
|
||||
"&:hover": { borderColor: "primary.main" },
|
||||
flex: 1,
|
||||
}}
|
||||
onClick={() => setSelectedOption("RandomSeed")}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 32, color: "text.secondary" }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
Create new wallet
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Restore from seed option */}
|
||||
<Card
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
border: selectedOption === "FromSeed" ? 2 : 1,
|
||||
borderColor:
|
||||
selectedOption === "FromSeed" ? "primary.main" : "divider",
|
||||
"&:hover": { borderColor: "primary.main" },
|
||||
flex: 1,
|
||||
}}
|
||||
onClick={() => setSelectedOption("FromSeed")}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 32, color: "text.secondary" }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
Restore from seed
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{selectedOption === "RandomSeed" && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
A new wallet with a random seed phrase will be generated.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
You will have the option to back it up later.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedOption === "FromSeed" && (
|
||||
<TextField
|
||||
|
|
@ -90,7 +257,6 @@ export default function SeedSelectionDialog() {
|
|||
label="Enter your seed phrase"
|
||||
value={customSeed}
|
||||
onChange={(e) => setCustomSeed(e.target.value)}
|
||||
sx={{ mt: 2 }}
|
||||
placeholder="Enter your Monero 25 words seed phrase..."
|
||||
error={!isSeedValid && customSeed.length > 0}
|
||||
helperText={
|
||||
|
|
@ -102,19 +268,115 @@ export default function SeedSelectionDialog() {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedOption === "FromWalletPath" && (
|
||||
<Box sx={{ gap: 2, display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Wallet file path"
|
||||
value={walletPath || ""}
|
||||
placeholder="Select a wallet file..."
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={selectWalletFile}
|
||||
sx={{ minWidth: "120px", height: "56px" }}
|
||||
startIcon={<SearchIcon />}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</Box>
|
||||
{recentWallets.length > 0 && (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
maxHeight: 200,
|
||||
overflowY: "scroll",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "block !important",
|
||||
width: "8px !important",
|
||||
},
|
||||
"&::-webkit-scrollbar-track": {
|
||||
display: "block !important",
|
||||
background: "rgba(255,255,255,.1) !important",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
display: "block !important",
|
||||
background: "rgba(255,255,255,.6) !important",
|
||||
borderRadius: "4px",
|
||||
minHeight: "20px !important",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb:hover": {
|
||||
background: "rgba(255,255,255,.8) !important",
|
||||
},
|
||||
"&::-webkit-scrollbar-corner": {
|
||||
background: "transparent !important",
|
||||
},
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "rgba(255,255,255,.6) rgba(255,255,255,.1)",
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{recentWallets.map((path, index) => (
|
||||
<Box key={index}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={walletPath === path}
|
||||
onClick={() => setWalletPath(path)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={path.split("/").pop() || path}
|
||||
secondary={path}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: walletPath === path ? 600 : 400,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
fontSize: "0.75rem",
|
||||
sx: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{index < recentWallets.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => handleClose(true)}
|
||||
variant="contained"
|
||||
disabled={
|
||||
selectedOption === "FromSeed"
|
||||
? !customSeed.trim() || !isSeedValid
|
||||
: false
|
||||
}
|
||||
<DialogActions sx={{ justifyContent: "space-between" }}>
|
||||
<PromiseInvokeButton
|
||||
variant="text"
|
||||
onInvoke={Legacy}
|
||||
requiresContext={false}
|
||||
color="inherit"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
No wallet (Legacy)
|
||||
</PromiseInvokeButton>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={accept}
|
||||
variant="contained"
|
||||
disabled={isDisabled}
|
||||
requiresContext={false}
|
||||
>
|
||||
Continue
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import { Box, DialogTitle, Typography } from "@mui/material";
|
||||
import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge";
|
||||
import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge";
|
||||
|
||||
export default function SwapDialogTitle({
|
||||
title,
|
||||
debug,
|
||||
setDebug,
|
||||
}: {
|
||||
title: string;
|
||||
debug: boolean;
|
||||
setDebug: (d: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gridGap: 1 }}>
|
||||
<FeedbackSubmitBadge />
|
||||
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export default function DebugPage() {
|
|||
}}
|
||||
>
|
||||
<CliLogsBox
|
||||
minHeight="min(20rem, 70vh)"
|
||||
logs={logs}
|
||||
label="Logs relevant to the swap (only current session)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,26 +5,35 @@ import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined";
|
|||
import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined";
|
||||
import RouteListItemIconButton from "./RouteListItemIconButton";
|
||||
import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge";
|
||||
import { useTotalUnreadMessagesCount } from "store/hooks";
|
||||
import { useIsSwapRunning, useTotalUnreadMessagesCount } from "store/hooks";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AttachMoneyIcon from "@mui/icons-material/AttachMoney";
|
||||
import BitcoinIcon from "../icons/BitcoinIcon";
|
||||
import MoneroIcon from "../icons/MoneroIcon";
|
||||
|
||||
export default function NavigationHeader() {
|
||||
const totalUnreadCount = useTotalUnreadMessagesCount();
|
||||
const isSwapRunning = useIsSwapRunning();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<List>
|
||||
<RouteListItemIconButton name="Swap" route="/swap">
|
||||
<SwapHorizOutlinedIcon />
|
||||
<RouteListItemIconButton name="Wallet" route={["/monero-wallet", "/"]}>
|
||||
<MoneroIcon />
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="Wallet" route="/bitcoin-wallet">
|
||||
<BitcoinIcon />
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="Swap" route={["/swap"]}>
|
||||
<Badge invisible={!isSwapRunning} variant="dot" color="primary">
|
||||
<SwapHorizOutlinedIcon />
|
||||
</Badge>
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="History" route="/history">
|
||||
<UnfinishedSwapsBadge>
|
||||
<HistoryOutlinedIcon />
|
||||
</UnfinishedSwapsBadge>
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="Wallet" route="/wallet">
|
||||
<AccountBalanceWalletIcon />
|
||||
</RouteListItemIconButton>
|
||||
<RouteListItemIconButton name="Feedback" route="/feedback">
|
||||
<Badge
|
||||
badgeContent={totalUnreadCount}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ListItemIcon, ListItemText } from "@mui/material";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
|
||||
|
|
@ -10,13 +10,31 @@ export default function RouteListItemIconButton({
|
|||
children,
|
||||
}: {
|
||||
name: string;
|
||||
route: string;
|
||||
route: string[] | string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const routeArray = Array.isArray(route) ? route : [route];
|
||||
const firstRoute = routeArray[0];
|
||||
const isSelected = routeArray.some((r) => location.pathname === r);
|
||||
|
||||
return (
|
||||
<ListItemButton onClick={() => navigate(route)} key={name}>
|
||||
<ListItemButton
|
||||
onClick={() => navigate(firstRoute)}
|
||||
key={name}
|
||||
sx={
|
||||
isSelected
|
||||
? {
|
||||
backgroundColor: "action.hover",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.selected",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ListItemIcon>{children}</ListItemIcon>
|
||||
<ListItemText primary={name} />
|
||||
</ListItemButton>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import React from "react";
|
||||
import { Badge } from "@mui/material";
|
||||
import { useResumeableSwapsCountExcludingPunished } from "store/hooks";
|
||||
import { useIsSwapRunning, useResumeableSwapsCountExcludingPunished } from "store/hooks";
|
||||
|
||||
export default function UnfinishedSwapsBadge({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isSwapRunning = useIsSwapRunning();
|
||||
const resumableSwapsCount = useResumeableSwapsCountExcludingPunished();
|
||||
|
||||
if (resumableSwapsCount > 0) {
|
||||
const displayedResumableSwapsCount = isSwapRunning ? resumableSwapsCount - 1 : resumableSwapsCount;
|
||||
|
||||
if (displayedResumableSwapsCount > 0) {
|
||||
return (
|
||||
<Badge badgeContent={resumableSwapsCount} color="primary">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type Props = {
|
|||
content: string;
|
||||
displayCopyIcon?: boolean;
|
||||
enableQrCode?: boolean;
|
||||
light?: boolean;
|
||||
};
|
||||
|
||||
function QRCodeModal({ open, onClose, content }: ModalProps) {
|
||||
|
|
@ -57,6 +58,7 @@ export default function ActionableMonospaceTextBox({
|
|||
content,
|
||||
displayCopyIcon = true,
|
||||
enableQrCode = true,
|
||||
light = false,
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrCodeOpen, setQrCodeOpen] = useState(false);
|
||||
|
|
@ -88,7 +90,7 @@ export default function ActionableMonospaceTextBox({
|
|||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
|
||||
<MonospaceTextBox>
|
||||
<MonospaceTextBox light={light}>
|
||||
{content}
|
||||
{displayCopyIcon && (
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@ import { Box, Typography } from "@mui/material";
|
|||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
light?: boolean;
|
||||
};
|
||||
|
||||
export default function MonospaceTextBox({ children }: Props) {
|
||||
export default function MonospaceTextBox({ children, light = false }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.palette.grey[900],
|
||||
backgroundColor: light ? "transparent" : theme.palette.grey[900],
|
||||
borderRadius: 2,
|
||||
border: light ? `1px solid ${theme.palette.grey[800]}` : "none",
|
||||
padding: theme.spacing(1),
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -63,11 +63,13 @@ export default function CliLogsBox({
|
|||
logs,
|
||||
topRightButton = null,
|
||||
autoScroll = false,
|
||||
minHeight,
|
||||
}: {
|
||||
label: string;
|
||||
logs: (CliLog | string)[];
|
||||
topRightButton?: ReactNode;
|
||||
autoScroll?: boolean;
|
||||
minHeight?: string;
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
|
||||
|
|
@ -82,6 +84,7 @@ export default function CliLogsBox({
|
|||
|
||||
return (
|
||||
<ScrollablePaperTextBox
|
||||
minHeight={minHeight}
|
||||
title={label}
|
||||
copyValue={logsToRawString(logs)}
|
||||
searchQuery={searchQuery}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default function ScrollablePaperTextBox({
|
|||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<VList ref={virtuaEl} style={{ height: MIN_HEIGHT, width: "100%" }}>
|
||||
<VList ref={virtuaEl} style={{ height: "100vh", width: "100%" }}>
|
||||
{rows}
|
||||
</VList>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function TruncatedText({
|
|||
let finalChildren = children ?? "";
|
||||
|
||||
const truncatedText =
|
||||
finalChildren.length > limit
|
||||
finalChildren.length > limit
|
||||
? truncateMiddle
|
||||
? finalChildren.slice(0, Math.floor(limit / 2)) +
|
||||
ellipsis +
|
||||
|
|
|
|||
|
|
@ -32,16 +32,43 @@ export function AmountWithUnit({
|
|||
return (
|
||||
<Tooltip arrow title={title}>
|
||||
<span>
|
||||
{amount != null
|
||||
? Number.parseFloat(amount.toFixed(fixedPrecision))
|
||||
: "?"}{" "}
|
||||
{unit}
|
||||
{amount != null ? amount.toFixed(fixedPrecision) : "?"} {unit}
|
||||
{parenthesisText != null ? ` (${parenthesisText})` : null}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function FiatPiconeroAmount({
|
||||
amount,
|
||||
fixedPrecision = 2,
|
||||
}: {
|
||||
amount: Amount;
|
||||
fixedPrecision?: number;
|
||||
}) {
|
||||
const xmrPrice = useAppSelector((state) => state.rates.xmrPrice);
|
||||
const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [
|
||||
settings.fetchFiatPrices,
|
||||
settings.fiatCurrency,
|
||||
]);
|
||||
|
||||
if (
|
||||
!fetchFiatPrices ||
|
||||
fiatCurrency == null ||
|
||||
amount == null ||
|
||||
xmrPrice == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{(piconerosToXmr(amount) * xmrPrice).toFixed(fixedPrecision)}{" "}
|
||||
{fiatCurrency}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
AmountWithUnit.defaultProps = {
|
||||
exchangeRate: null,
|
||||
};
|
||||
|
|
@ -59,14 +86,20 @@ export function BitcoinAmount({ amount }: { amount: Amount }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function MoneroAmount({ amount }: { amount: Amount }) {
|
||||
export function MoneroAmount({
|
||||
amount,
|
||||
fixedPrecision = 4,
|
||||
}: {
|
||||
amount: Amount;
|
||||
fixedPrecision?: number;
|
||||
}) {
|
||||
const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
|
||||
|
||||
return (
|
||||
<AmountWithUnit
|
||||
amount={amount}
|
||||
unit="XMR"
|
||||
fixedPrecision={4}
|
||||
fixedPrecision={fixedPrecision}
|
||||
exchangeRate={xmrRate}
|
||||
/>
|
||||
);
|
||||
|
|
@ -128,8 +161,17 @@ export function SatsAmount({ amount }: { amount: Amount }) {
|
|||
return <BitcoinAmount amount={btcAmount} />;
|
||||
}
|
||||
|
||||
export function PiconeroAmount({ amount }: { amount: Amount }) {
|
||||
export function PiconeroAmount({
|
||||
amount,
|
||||
fixedPrecision = 8,
|
||||
}: {
|
||||
amount: Amount;
|
||||
fixedPrecision?: number;
|
||||
}) {
|
||||
return (
|
||||
<MoneroAmount amount={amount == null ? null : piconerosToXmr(amount)} />
|
||||
<MoneroAmount
|
||||
amount={amount == null ? null : piconerosToXmr(amount)}
|
||||
fixedPrecision={fixedPrecision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import HistoryTable from "./table/HistoryTable";
|
|||
export default function HistoryPage() {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h3">History</Typography>
|
||||
<SwapTxLockAlertsBox />
|
||||
<HistoryTable />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
Skeleton,
|
||||
} from "@mui/material";
|
||||
import { useSwapInfosSortedByDate } from "../../../../../store/hooks";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
|
|
@ -23,19 +25,75 @@ export default function HistoryTable() {
|
|||
>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>State</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{swapSortedByDate.length > 0 && (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>State</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableBody>
|
||||
{swapSortedByDate.map((swap) => (
|
||||
<HistoryRow {...swap} key={swap.swap_id} />
|
||||
))}
|
||||
{swapSortedByDate.length === 0 ? (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ textAlign: "center", py: 4 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
Nothing to see here
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
You haven't made any swaps yet
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* Skeleton rows for visual loading effect */}
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton
|
||||
animation={false}
|
||||
variant="circular"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation={false} variant="text" width="80%" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation={false} variant="text" width="60%" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton
|
||||
animation={false}
|
||||
variant="rectangular"
|
||||
width={80}
|
||||
height={24}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton
|
||||
animation={false}
|
||||
variant="circular"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
swapSortedByDate.map((swap) => (
|
||||
<HistoryRow {...swap} key={swap.swap_id} />
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ export default function SwapLogFileOpenButton({
|
|||
<Dialog open onClose={() => setLogs(null)} fullWidth maxWidth="lg">
|
||||
<DialogTitle>Logs of swap {swapId}</DialogTitle>
|
||||
<DialogContent>
|
||||
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
|
||||
<CliLogsBox
|
||||
minHeight="min(20rem, 70vh)"
|
||||
logs={logs}
|
||||
label="Logs relevant to the swap"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { useEffect } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import { initializeMoneroWallet } from "renderer/rpc";
|
||||
import {
|
||||
WalletOverview,
|
||||
TransactionHistory,
|
||||
WalletActionButtons,
|
||||
} from "./components";
|
||||
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
|
||||
import WalletPageLoadingState from "./components/WalletPageLoadingState";
|
||||
|
||||
// Main MoneroWalletPage component
|
||||
export default function MoneroWalletPage() {
|
||||
const { mainAddress, balance, syncProgress, history } = useAppSelector(
|
||||
(state) => state.wallet.state,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initializeMoneroWallet();
|
||||
}, []);
|
||||
|
||||
const isLoading = balance === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <WalletPageLoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 800,
|
||||
mx: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<WalletOverview balance={balance} syncProgress={syncProgress} />
|
||||
<ActionableMonospaceTextBox
|
||||
content={mainAddress}
|
||||
displayCopyIcon={true}
|
||||
/>
|
||||
<WalletActionButtons balance={balance} />
|
||||
<TransactionHistory history={history} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Dialog } from "@mui/material";
|
||||
import SendTransactionContent from "./components/SendTransactionContent";
|
||||
import SendApprovalContent from "./components/SendApprovalContent";
|
||||
import { useState } from "react";
|
||||
import SendSuccessContent from "./components/SendSuccessContent";
|
||||
import { usePendingSendMoneroApproval } from "store/hooks";
|
||||
import { SendMoneroResponse } from "models/tauriModel";
|
||||
|
||||
interface SendTransactionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
balance: {
|
||||
unlocked_balance: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SendTransactionModal({
|
||||
balance,
|
||||
open,
|
||||
onClose,
|
||||
}: SendTransactionModalProps) {
|
||||
const pendingApprovals = usePendingSendMoneroApproval();
|
||||
const hasPendingApproval = pendingApprovals.length > 0;
|
||||
|
||||
const [successResponse, setSuccessResponse] = useState<SendMoneroResponse | null>(null);
|
||||
|
||||
const showSuccess = successResponse !== null;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setSuccessResponse(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth={!showSuccess}
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 },
|
||||
}}
|
||||
>
|
||||
{!showSuccess && !hasPendingApproval && (
|
||||
<SendTransactionContent balance={balance} onClose={onClose} onSuccess={setSuccessResponse} />
|
||||
)}
|
||||
{!showSuccess && hasPendingApproval && (
|
||||
<SendApprovalContent onClose={onClose} />
|
||||
)}
|
||||
{showSuccess && (
|
||||
<SendSuccessContent onClose={onClose} successDetails={successResponse} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
TextField,
|
||||
Typography,
|
||||
Radio,
|
||||
} from "@mui/material";
|
||||
|
||||
import { DialogTitle } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getRestoreHeight, setMoneroRestoreHeight } from "renderer/rpc";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { Dayjs } from "dayjs";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
|
||||
enum RestoreOption {
|
||||
BlockHeight = "blockHeight",
|
||||
RestoreDate = "restoreDate",
|
||||
}
|
||||
|
||||
export default function SetRestoreHeightModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [restoreOption, setRestoreOption] = useState(RestoreOption.BlockHeight);
|
||||
const [restoreHeight, setRestoreHeight] = useState<number | "">("");
|
||||
const [restoreDate, setRestoreDate] = useState<Dayjs | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [currentRestoreHeight, setCurrentRestoreHeight] =
|
||||
useState<string>("Loading...");
|
||||
|
||||
const handleRestoreHeight = async () => {
|
||||
if (restoreOption === RestoreOption.BlockHeight) {
|
||||
if (typeof restoreHeight === "number") {
|
||||
await setMoneroRestoreHeight(restoreHeight);
|
||||
}
|
||||
} else if (restoreOption === RestoreOption.RestoreDate) {
|
||||
if (restoreDate) {
|
||||
await setMoneroRestoreHeight(restoreDate.toDate());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentRestoreHeight = async () => {
|
||||
try {
|
||||
const response = await getRestoreHeight();
|
||||
setCurrentRestoreHeight(response.height.toString());
|
||||
setRestoreHeight(response.height); // Set the input field to current height
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch restore height:", error);
|
||||
setCurrentRestoreHeight("Error");
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchCurrentRestoreHeight();
|
||||
}
|
||||
}, [open, isPending]);
|
||||
|
||||
const accordionStyle = {
|
||||
"& .MuiAccordionSummary-content": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1,
|
||||
},
|
||||
"&::before": {
|
||||
opacity: "1 !important",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Set Restore Height</DialogTitle>
|
||||
<DialogContent sx={{ minWidth: "500px", minHeight: "300px" }}>
|
||||
<Accordion
|
||||
elevation={0}
|
||||
expanded={restoreOption === RestoreOption.BlockHeight}
|
||||
onChange={() => setRestoreOption(RestoreOption.BlockHeight)}
|
||||
disableGutters
|
||||
sx={accordionStyle}
|
||||
>
|
||||
<AccordionSummary>
|
||||
<Typography>Restore by block height</Typography>
|
||||
<Radio checked={restoreOption === RestoreOption.BlockHeight} />
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TextField
|
||||
label="Restore Height"
|
||||
type="number"
|
||||
value={restoreHeight}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setRestoreHeight(value === "" ? "" : Number(value));
|
||||
}}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion
|
||||
elevation={0}
|
||||
expanded={restoreOption === RestoreOption.RestoreDate}
|
||||
onChange={() => setRestoreOption(RestoreOption.RestoreDate)}
|
||||
disableGutters
|
||||
sx={accordionStyle}
|
||||
>
|
||||
<AccordionSummary>
|
||||
<Typography>Restore by date</Typography>
|
||||
<Radio checked={restoreOption === RestoreOption.RestoreDate} />
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<DatePicker
|
||||
label="Restore Date"
|
||||
value={restoreDate}
|
||||
disableFuture
|
||||
onChange={(date) => setRestoreDate(date)}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={handleRestoreHeight}
|
||||
onSuccess={onClose}
|
||||
displayErrorSnackbar={true}
|
||||
onPendingChange={setIsPending}
|
||||
>
|
||||
Confirm
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import { Box, Button, Card, Grow, Typography } from "@mui/material";
|
||||
import NumberInput from "renderer/components/inputs/NumberInput";
|
||||
import SwapVertIcon from "@mui/icons-material/SwapVert";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { piconerosToXmr } from "../../../../../utils/conversionUtils";
|
||||
import { MoneroAmount } from "renderer/components/other/Units";
|
||||
|
||||
interface SendAmountInputProps {
|
||||
balance: {
|
||||
unlocked_balance: string;
|
||||
};
|
||||
amount: string;
|
||||
onAmountChange: (amount: string) => void;
|
||||
onMaxClicked?: () => void;
|
||||
onMaxToggled?: () => void;
|
||||
currency: string;
|
||||
onCurrencyChange: (currency: string) => void;
|
||||
fiatCurrency: string;
|
||||
xmrPrice: number;
|
||||
showFiatRate: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function SendAmountInput({
|
||||
balance,
|
||||
amount,
|
||||
currency,
|
||||
onCurrencyChange,
|
||||
onAmountChange,
|
||||
onMaxClicked,
|
||||
onMaxToggled,
|
||||
fiatCurrency,
|
||||
xmrPrice,
|
||||
showFiatRate,
|
||||
disabled = false,
|
||||
}: SendAmountInputProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const isMaxSelected = amount === "<MAX>";
|
||||
|
||||
// Calculate secondary amount for display
|
||||
const secondaryAmount = (() => {
|
||||
if (isMaxSelected) {
|
||||
return "All available funds";
|
||||
}
|
||||
|
||||
if (!amount || amount === "" || isNaN(parseFloat(amount))) {
|
||||
return "0.00";
|
||||
}
|
||||
|
||||
const primaryValue = parseFloat(amount);
|
||||
if (currency === "XMR") {
|
||||
// Primary is XMR, secondary is USD
|
||||
return (primaryValue * xmrPrice).toFixed(2);
|
||||
} else {
|
||||
// Primary is USD, secondary is XMR
|
||||
return (primaryValue / xmrPrice).toFixed(3);
|
||||
}
|
||||
})();
|
||||
|
||||
const handleMaxAmount = () => {
|
||||
if (disabled) return;
|
||||
|
||||
if (onMaxToggled) {
|
||||
onMaxToggled();
|
||||
} else if (onMaxClicked) {
|
||||
onMaxClicked();
|
||||
} else {
|
||||
// Fallback to old behavior if no callback provided
|
||||
if (
|
||||
balance?.unlocked_balance !== undefined &&
|
||||
balance?.unlocked_balance !== null
|
||||
) {
|
||||
// TODO: We need to use a real fee here and call sweep(...) instead of just subtracting a fixed amount
|
||||
const unlocked = parseFloat(balance.unlocked_balance);
|
||||
const maxAmountXmr = piconerosToXmr(unlocked - 10000000000); // Subtract ~0.01 XMR for fees
|
||||
|
||||
if (currency === "XMR") {
|
||||
onAmountChange(Math.max(0, maxAmountXmr).toString());
|
||||
} else {
|
||||
// Convert to USD for display
|
||||
const maxAmountUsd = maxAmountXmr * xmrPrice;
|
||||
onAmountChange(Math.max(0, maxAmountUsd).toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaxTextClick = () => {
|
||||
if (disabled) return;
|
||||
if (isMaxSelected && onMaxToggled) {
|
||||
onMaxToggled();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrencySwap = () => {
|
||||
if (!isMaxSelected && !disabled) {
|
||||
onCurrencyChange(currency === "XMR" ? fiatCurrency : "XMR");
|
||||
}
|
||||
};
|
||||
|
||||
const isAmountTooHigh =
|
||||
!isMaxSelected &&
|
||||
(currency === "XMR"
|
||||
? parseFloat(amount) >
|
||||
piconerosToXmr(parseFloat(balance.unlocked_balance))
|
||||
: parseFloat(amount) / xmrPrice >
|
||||
piconerosToXmr(parseFloat(balance.unlocked_balance)));
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
tabIndex={0}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `1px solid ${theme.palette.grey[800]}`,
|
||||
width: "100%",
|
||||
height: 250,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
|
||||
>
|
||||
{isAmountTooHigh && (
|
||||
<Grow
|
||||
in
|
||||
style={{ transitionDelay: isAmountTooHigh ? "100ms" : "0ms" }}
|
||||
>
|
||||
<Typography variant="caption" align="center" color="error">
|
||||
You don't have enough
|
||||
<br /> unlocked balance to send this amount.
|
||||
</Typography>
|
||||
</Grow>
|
||||
)}
|
||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
|
||||
{isMaxSelected ? (
|
||||
<Typography
|
||||
variant="h3"
|
||||
onClick={handleMaxTextClick}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "primary.main",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
userSelect: "none",
|
||||
"&:hover": {
|
||||
opacity: disabled ? 1 : 0.8,
|
||||
},
|
||||
}}
|
||||
title={disabled ? "" : "Click to edit amount"}
|
||||
>
|
||||
<MAX>
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<NumberInput
|
||||
value={amount}
|
||||
onChange={disabled ? () => {} : onAmountChange}
|
||||
placeholder={currency === "XMR" ? "0.000" : "0.00"}
|
||||
fontSize="3em"
|
||||
fontWeight={600}
|
||||
minWidth={60}
|
||||
step={currency === "XMR" ? 0.001 : 0.01}
|
||||
largeStep={currency === "XMR" ? 0.1 : 10}
|
||||
/>
|
||||
<Typography variant="h4" color="text.secondary">
|
||||
{currency}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{showFiatRate && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SwapVertIcon
|
||||
onClick={handleCurrencySwap}
|
||||
sx={{
|
||||
cursor: isMaxSelected || disabled ? "default" : "pointer",
|
||||
opacity: isMaxSelected || disabled ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
<Typography color="text.secondary">
|
||||
{secondaryAmount}{" "}
|
||||
{isMaxSelected ? "" : currency === "XMR" ? fiatCurrency : "XMR"}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
gap: 1.5,
|
||||
position: "absolute",
|
||||
bottom: 12,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Available</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 0.5 }}>
|
||||
<Typography color="text.primary">
|
||||
<MoneroAmount
|
||||
amount={piconerosToXmr(parseFloat(balance.unlocked_balance))}
|
||||
/>
|
||||
</Typography>
|
||||
<Typography color="text.secondary">XMR</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant={isMaxSelected ? "contained" : "secondary"}
|
||||
size="tiny"
|
||||
onClick={handleMaxAmount}
|
||||
disabled={disabled}
|
||||
>
|
||||
Max
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { resolveApproval } from "renderer/rpc";
|
||||
import { usePendingSendMoneroApproval } from "store/hooks";
|
||||
import { PiconeroAmount } from "renderer/components/other/Units";
|
||||
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
|
||||
interface SendApprovalContentProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SendApprovalContent({
|
||||
onClose,
|
||||
}: SendApprovalContentProps) {
|
||||
const pendingApprovals = usePendingSendMoneroApproval();
|
||||
const [timeLeft, setTimeLeft] = useState<number>(0);
|
||||
|
||||
const approval = pendingApprovals[0]; // Handle the first approval request
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!approval?.request_status ||
|
||||
approval.request_status.state !== "Pending"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expirationTs = approval.request_status.content.expiration_ts;
|
||||
const expiresAtMs = expirationTs * 1000;
|
||||
|
||||
const tick = () => {
|
||||
const remainingMs = Math.max(expiresAtMs - Date.now(), 0);
|
||||
setTimeLeft(Math.ceil(remainingMs / 1000));
|
||||
};
|
||||
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [approval]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approval) throw new Error("No approval request available");
|
||||
await resolveApproval(approval.request_id, true);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!approval) throw new Error("No approval request available");
|
||||
await resolveApproval(approval.request_id, false);
|
||||
};
|
||||
|
||||
if (!approval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { address, amount, fee } = approval.request.content;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>
|
||||
<Typography variant="h6" component="div">
|
||||
Confirm Monero Transfer
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{/* Amount */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Amount to Send
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
<PiconeroAmount amount={amount} fixedPrecision={12} />
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Fee */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Network Fee
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
<PiconeroAmount amount={fee} fixedPrecision={12} />
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Destination Address */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Destination Address
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
<ActionableMonospaceTextBox
|
||||
content={address}
|
||||
displayCopyIcon={true}
|
||||
enableQrCode={false}
|
||||
light={true}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Time remaining */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
{timeLeft > 0
|
||||
? `Request expires in ${timeLeft}s`
|
||||
: "Request expired"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 3, gap: 1 }}>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={handleReject}
|
||||
onSuccess={onClose}
|
||||
disabled={timeLeft === 0}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<CloseIcon />}
|
||||
displayErrorSnackbar={true}
|
||||
requiresContext={false}
|
||||
>
|
||||
Reject
|
||||
</PromiseInvokeButton>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={handleApprove}
|
||||
disabled={timeLeft === 0}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<CheckIcon />}
|
||||
displayErrorSnackbar={true}
|
||||
requiresContext={false}
|
||||
>
|
||||
Send
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Box, Button, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import { FiatPiconeroAmount, PiconeroAmount } from "renderer/components/other/Units";
|
||||
import MonospaceTextBox from "renderer/components/other/MonospaceTextBox";
|
||||
import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward";
|
||||
import { SendMoneroResponse } from "models/tauriModel";
|
||||
import { getMoneroTxExplorerUrl } from "../../../../../utils/conversionUtils";
|
||||
import { isTestnet } from "store/config";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
export default function SendSuccessContent({
|
||||
onClose,
|
||||
successDetails,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
successDetails: SendMoneroResponse | null;
|
||||
}) {
|
||||
|
||||
const address = successDetails?.address;
|
||||
const amount = successDetails?.amount_sent;
|
||||
const explorerUrl = getMoneroTxExplorerUrl(successDetails?.tx_hash, isTestnet());
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "400px",
|
||||
minWidth: "500px",
|
||||
gap: 7,
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon sx={{ fontSize: 64, mt: 3 }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">Transaction Published</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="body1" color="text.secondary">Sent</Typography>
|
||||
<Typography variant="body1" color="text.primary">
|
||||
<PiconeroAmount amount={amount} fixedPrecision={4}/>
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">(<FiatPiconeroAmount amount={amount} />)</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="body1" color="text.secondary">to</Typography>
|
||||
<Typography variant="body1" color="text.primary">
|
||||
<MonospaceTextBox>{address.slice(0, 8)} ... {address.slice(-8)}</MonospaceTextBox>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<Button onClick={onClose} variant="contained" color="primary">Done</Button>
|
||||
<Button color="primary" size="small" endIcon={<ArrowOutwardIcon />} onClick={() => open(explorerUrl)}>View on Explorer</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import {
|
||||
Button,
|
||||
Box,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { xmrToPiconeros } from "../../../../../utils/conversionUtils";
|
||||
import SendAmountInput from "./SendAmountInput";
|
||||
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import { sendMoneroTransaction } from "renderer/rpc";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import { SendMoneroResponse } from "models/tauriModel";
|
||||
|
||||
interface SendTransactionContentProps {
|
||||
balance: {
|
||||
unlocked_balance: string;
|
||||
};
|
||||
onClose: () => void;
|
||||
onSuccess: (response: SendMoneroResponse) => void;
|
||||
}
|
||||
|
||||
export default function SendTransactionContent({
|
||||
balance,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: SendTransactionContentProps) {
|
||||
const [sendAddress, setSendAddress] = useState("");
|
||||
const [sendAmount, setSendAmount] = useState("");
|
||||
const [previousAmount, setPreviousAmount] = useState("");
|
||||
const [enableSend, setEnableSend] = useState(false);
|
||||
const [currency, setCurrency] = useState("XMR");
|
||||
const [isMaxSelected, setIsMaxSelected] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const showFiatRate = useAppSelector(
|
||||
(state) => state.settings.fetchFiatPrices,
|
||||
);
|
||||
const fiatCurrency = useAppSelector((state) => state.settings.fiatCurrency);
|
||||
const xmrPrice = useAppSelector((state) => state.rates.xmrPrice);
|
||||
|
||||
const handleCurrencyChange = (newCurrency: string) => {
|
||||
if (!showFiatRate || !xmrPrice || isMaxSelected || isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendAmount === "" || parseFloat(sendAmount) === 0) {
|
||||
setSendAmount(newCurrency === "XMR" ? "0.000" : "0.00");
|
||||
} else {
|
||||
setSendAmount(
|
||||
newCurrency === "XMR"
|
||||
? (parseFloat(sendAmount) / xmrPrice).toFixed(3)
|
||||
: (parseFloat(sendAmount) * xmrPrice).toFixed(2),
|
||||
);
|
||||
}
|
||||
setCurrency(newCurrency);
|
||||
};
|
||||
|
||||
const handleMaxToggled = () => {
|
||||
if (isSending) return;
|
||||
|
||||
if (isMaxSelected) {
|
||||
// Disable MAX mode - restore previous amount
|
||||
setIsMaxSelected(false);
|
||||
setSendAmount(previousAmount);
|
||||
} else {
|
||||
// Enable MAX mode - save current amount first
|
||||
setPreviousAmount(sendAmount);
|
||||
setIsMaxSelected(true);
|
||||
setSendAmount("<MAX>");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAmountChange = (newAmount: string) => {
|
||||
if (isSending) return;
|
||||
|
||||
if (newAmount !== "<MAX>") {
|
||||
setIsMaxSelected(false);
|
||||
}
|
||||
setSendAmount(newAmount);
|
||||
};
|
||||
|
||||
const handleAddressChange = (newAddress: string) => {
|
||||
if (isSending) return;
|
||||
setSendAddress(newAddress);
|
||||
};
|
||||
|
||||
const moneroAmount =
|
||||
currency === "XMR"
|
||||
? parseFloat(sendAmount)
|
||||
: parseFloat(sendAmount) / xmrPrice;
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!sendAddress) {
|
||||
throw new Error("Address is required");
|
||||
}
|
||||
|
||||
if (isMaxSelected) {
|
||||
return sendMoneroTransaction({
|
||||
address: sendAddress,
|
||||
amount: { type: "Sweep" },
|
||||
});
|
||||
} else {
|
||||
if (!sendAmount || sendAmount === "<MAX>") {
|
||||
throw new Error("Amount is required");
|
||||
}
|
||||
|
||||
return sendMoneroTransaction({
|
||||
address: sendAddress,
|
||||
amount: {
|
||||
type: "Specific",
|
||||
// Floor the amount to avoid rounding decimal amounts
|
||||
// The amount is in piconeros, so it NEEDS to be a whole number
|
||||
amount: Math.floor(xmrToPiconeros(moneroAmount)),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSuccess = (response: SendMoneroResponse) => {
|
||||
// Clear form after successful send
|
||||
handleClear();
|
||||
onSuccess(response);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSendAddress("");
|
||||
setSendAmount("");
|
||||
setPreviousAmount("");
|
||||
setIsMaxSelected(false);
|
||||
};
|
||||
|
||||
const isSendDisabled =
|
||||
!enableSend || (!isMaxSelected && (!sendAmount || sendAmount === "<MAX>"));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>Send</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<SendAmountInput
|
||||
balance={balance}
|
||||
amount={sendAmount}
|
||||
onAmountChange={handleAmountChange}
|
||||
onMaxToggled={handleMaxToggled}
|
||||
currency={currency}
|
||||
fiatCurrency={fiatCurrency}
|
||||
xmrPrice={xmrPrice}
|
||||
showFiatRate={showFiatRate}
|
||||
onCurrencyChange={handleCurrencyChange}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<MoneroAddressTextField
|
||||
address={sendAddress}
|
||||
onAddressChange={handleAddressChange}
|
||||
onAddressValidityChange={setEnableSend}
|
||||
label="Send to"
|
||||
fullWidth
|
||||
disabled={isSending}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={handleSend}
|
||||
disabled={isSendDisabled}
|
||||
onSuccess={handleSendSuccess}
|
||||
onPendingChange={setIsSending}
|
||||
>
|
||||
Send
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Box, darken, lighten, useTheme } from "@mui/material";
|
||||
|
||||
function getColor(colorName: string) {
|
||||
const theme = useTheme();
|
||||
switch (colorName) {
|
||||
case "primary":
|
||||
return theme.palette.primary.main;
|
||||
case "secondary":
|
||||
return theme.palette.secondary.main;
|
||||
case "success":
|
||||
return theme.palette.success.main;
|
||||
case "warning":
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
}
|
||||
|
||||
export default function StateIndicator({
|
||||
color,
|
||||
pulsating,
|
||||
}: {
|
||||
color: string;
|
||||
pulsating: boolean;
|
||||
}) {
|
||||
const mainShade = getColor(color);
|
||||
const darkShade = darken(mainShade, 0.4);
|
||||
const glowShade = lighten(mainShade, 0.4);
|
||||
|
||||
const intensePulsatingStyles = {
|
||||
animation: "pulse 2s infinite",
|
||||
"@keyframes pulse": {
|
||||
"0%": { opacity: 0.5 },
|
||||
"50%": { opacity: 1 },
|
||||
"100%": { opacity: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
const softPulsatingStyles = {
|
||||
animation: "pulse 3.5s infinite",
|
||||
"@keyframes pulse": {
|
||||
"0%": { opacity: 0.7 },
|
||||
"50%": { opacity: 1 },
|
||||
"100%": { opacity: 0.7 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
backgroundImage: `radial-gradient(circle, ${mainShade}, ${darkShade})`,
|
||||
boxShadow: `0 0 10px 0 ${glowShade}`,
|
||||
...(pulsating ? intensePulsatingStyles : softPulsatingStyles),
|
||||
}}
|
||||
></Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import { OpenInNew as OpenInNewIcon } from "@mui/icons-material";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { PiconeroAmount } from "../../../other/Units";
|
||||
import { getMoneroTxExplorerUrl } from "../../../../../utils/conversionUtils";
|
||||
import { isTestnet } from "store/config";
|
||||
import { TransactionInfo } from "models/tauriModel";
|
||||
|
||||
interface TransactionHistoryProps {
|
||||
history?: {
|
||||
transactions: TransactionInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
// Component for displaying transaction history
|
||||
export default function TransactionHistory({
|
||||
history,
|
||||
}: TransactionHistoryProps) {
|
||||
if (!history || !history.transactions || history.transactions.length === 0) {
|
||||
return <Typography variant="h5">Transaction History</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5">Transaction History</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>Fee</TableCell>
|
||||
<TableCell align="right">Confirmations</TableCell>
|
||||
<TableCell align="center">Explorer</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{[...history.transactions]
|
||||
.sort((a, b) => a.confirmations - b.confirmations)
|
||||
.map((tx, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PiconeroAmount amount={tx.amount} />
|
||||
<Chip
|
||||
label={tx.direction === "In" ? "Received" : "Sent"}
|
||||
color={tx.direction === "In" ? "success" : "default"}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PiconeroAmount amount={tx.fee} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={tx.confirmations}
|
||||
color={tx.confirmations >= 10 ? "success" : "warning"}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{tx.tx_hash && (
|
||||
<Tooltip title="View on block explorer">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const url = getMoneroTxExplorerUrl(
|
||||
tx.tx_hash,
|
||||
isTestnet(),
|
||||
);
|
||||
open(url);
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Send as SendIcon,
|
||||
SwapHoriz as SwapIcon,
|
||||
Restore as RestoreIcon,
|
||||
MoreHoriz as MoreHorizIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useState } from "react";
|
||||
import { setMoneroRestoreHeight } from "renderer/rpc";
|
||||
import SendTransactionModal from "../SendTransactionModal";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import SetRestoreHeightModal from "../SetRestoreHeightModal";
|
||||
|
||||
interface WalletActionButtonsProps {
|
||||
balance: {
|
||||
unlocked_balance: string;
|
||||
};
|
||||
}
|
||||
|
||||
function RestoreHeightDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [restoreHeight, setRestoreHeight] = useState(0);
|
||||
|
||||
const handleRestoreHeight = async () => {
|
||||
await setMoneroRestoreHeight(restoreHeight);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Restore Height</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="Restore Height"
|
||||
type="number"
|
||||
value={restoreHeight}
|
||||
onChange={(e) => setRestoreHeight(Number(e.target.value))}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<PromiseInvokeButton
|
||||
onInvoke={handleRestoreHeight}
|
||||
displayErrorSnackbar={true}
|
||||
variant="contained"
|
||||
>
|
||||
Restore
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WalletActionButtons({
|
||||
balance,
|
||||
}: WalletActionButtonsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [restoreHeightDialogOpen, setRestoreHeightDialogOpen] = useState(false);
|
||||
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(menuAnchorEl);
|
||||
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetRestoreHeightModal
|
||||
open={restoreHeightDialogOpen}
|
||||
onClose={() => setRestoreHeightDialogOpen(false)}
|
||||
/>
|
||||
<SendTransactionModal
|
||||
balance={balance}
|
||||
open={sendDialogOpen}
|
||||
onClose={() => setSendDialogOpen(false)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={<SendIcon />}
|
||||
label="Send"
|
||||
variant="button"
|
||||
clickable
|
||||
onClick={() => setSendDialogOpen(true)}
|
||||
/>
|
||||
<Chip
|
||||
onClick={() => navigate("/swap")}
|
||||
icon={<SwapIcon />}
|
||||
label="Swap"
|
||||
variant="button"
|
||||
clickable
|
||||
/>
|
||||
|
||||
<IconButton onClick={handleMenuClick}>
|
||||
<MoreHorizIcon />
|
||||
</IconButton>
|
||||
<Menu anchorEl={menuAnchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setRestoreHeightDialogOpen(true);
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RestoreIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Restore Height</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import {
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
CardHeader,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
import { PiconeroAmount } from "../../../other/Units";
|
||||
import { FiatPiconeroAmount } from "../../../other/Units";
|
||||
import StateIndicator from "./StateIndicator";
|
||||
|
||||
interface WalletOverviewProps {
|
||||
balance?: {
|
||||
unlocked_balance: string;
|
||||
total_balance: string;
|
||||
};
|
||||
syncProgress?: {
|
||||
current_block: number;
|
||||
target_block: number;
|
||||
progress_percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Component for displaying wallet address and balance
|
||||
export default function WalletOverview({
|
||||
balance,
|
||||
syncProgress,
|
||||
}: WalletOverviewProps) {
|
||||
const pendingBalance =
|
||||
parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance);
|
||||
|
||||
const isSyncing = syncProgress && syncProgress.progress_percentage < 100;
|
||||
const blocksLeft = syncProgress?.target_block - syncProgress?.current_block;
|
||||
|
||||
return (
|
||||
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
|
||||
{syncProgress && syncProgress.progress_percentage < 100 && (
|
||||
<LinearProgress
|
||||
value={syncProgress.progress_percentage}
|
||||
variant="determinate"
|
||||
sx={{
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Balance */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1.5fr 1fr max-content",
|
||||
rowGap: 0.5,
|
||||
columnGap: 2,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
|
||||
>
|
||||
Available Funds
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
|
||||
<PiconeroAmount
|
||||
amount={parseFloat(balance.unlocked_balance)}
|
||||
fixedPrecision={4}
|
||||
/>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ gridColumn: "1", gridRow: "3" }}
|
||||
>
|
||||
<FiatPiconeroAmount amount={parseFloat(balance.unlocked_balance)} />
|
||||
</Typography>
|
||||
{pendingBalance > 0 && (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="warning"
|
||||
sx={{
|
||||
mb: 1,
|
||||
animation: "pulse 2s infinite",
|
||||
gridColumn: "2",
|
||||
gridRow: "1",
|
||||
alignSelf: "end",
|
||||
}}
|
||||
>
|
||||
Pending
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ gridColumn: "2", gridRow: "2", alignSelf: "center" }}
|
||||
>
|
||||
<PiconeroAmount amount={pendingBalance} fixedPrecision={4} />
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ gridColumn: "2", gridRow: "3" }}
|
||||
>
|
||||
<FiatPiconeroAmount amount={pendingBalance} />
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{isSyncing ? "syncing" : "synced"}
|
||||
</Typography>
|
||||
<StateIndicator
|
||||
color={isSyncing ? "primary" : "success"}
|
||||
pulsating={isSyncing}
|
||||
/>
|
||||
</Box>
|
||||
{isSyncing && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{blocksLeft.toLocaleString()} blocks left
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { Box, Card, Chip, Skeleton, Typography } from "@mui/material";
|
||||
import StateIndicator from "./StateIndicator";
|
||||
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
|
||||
|
||||
const DUMMY_ADDRESS =
|
||||
"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H";
|
||||
|
||||
export default function WalletPageLoadingState() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 800,
|
||||
mx: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
|
||||
{/* Balance */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1.5fr 1fr max-content",
|
||||
rowGap: 0.5,
|
||||
columnGap: 2,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
|
||||
>
|
||||
Available Funds
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
|
||||
<Skeleton variant="text" width="80%" />
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ gridColumn: "1", gridRow: "3" }}
|
||||
>
|
||||
<Skeleton variant="text" width="40%" />
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">loading</Typography>
|
||||
<StateIndicator color="primary" pulsating={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Skeleton variant="rounded" width="100%">
|
||||
<ActionableMonospaceTextBox content={DUMMY_ADDRESS} />
|
||||
</Skeleton>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "row", gap: 2, mb: 2 }}>
|
||||
{Array.from({ length: 2 }).map((_) => (
|
||||
<Skeleton variant="rounded" sx={{ borderRadius: "100px" }}>
|
||||
<Chip label="Loading..." variant="button" />
|
||||
</Skeleton>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5">Transaction History</Typography>
|
||||
<Skeleton variant="rounded" width="100%" height={40} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { default as WalletOverview } from "./WalletOverview";
|
||||
export { default as TransactionHistory } from "./TransactionHistory";
|
||||
export { default as WalletActionButtons } from "./WalletActionButtons";
|
||||
export { default as SendTransactionContent } from "./SendTransactionContent";
|
||||
export { default as SendApprovalContent } from "./SendApprovalContent";
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode } from "react";
|
||||
import { Box, Link, Typography } from "@mui/material";
|
||||
import { ReactNode } from "react";
|
||||
import InfoBox from "./InfoBox";
|
||||
import TruncatedText from "renderer/components/other/TruncatedText";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export default function InitPage() {
|
|||
useState(false);
|
||||
|
||||
// We force this to true for now because the internal wallet is not really accessible from the GUI yet
|
||||
const [useExternalRedeemAddress, _setUseExternalRedeemAddress] =
|
||||
useState(true);
|
||||
const [useExternalRedeemAddress, setUseExternalRedeemAddress] =
|
||||
useState(true);
|
||||
|
||||
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
|
||||
const [refundAddressValid, setRefundAddressValid] = useState(false);
|
||||
|
|
@ -40,14 +40,35 @@ export default function InitPage() {
|
|||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{}}>
|
||||
<MoneroAddressTextField
|
||||
label="Monero redeem address"
|
||||
address={redeemAddress}
|
||||
onAddressChange={setRedeemAddress}
|
||||
onAddressValidityChange={setRedeemAddressValid}
|
||||
fullWidth
|
||||
helperText="The monero will be sent to this address"
|
||||
/>
|
||||
<Tabs
|
||||
value={useExternalRedeemAddress ? 1 : 0}
|
||||
indicatorColor="primary"
|
||||
variant="fullWidth"
|
||||
onChange={(_, newValue) =>
|
||||
setUseExternalRedeemAddress(newValue === 1)
|
||||
}
|
||||
>
|
||||
<Tab label="Redeem to internal Monero wallet" value={0} />
|
||||
<Tab label="Redeem to external Monero address" value={1} />
|
||||
</Tabs>
|
||||
<Box style={{ padding: "16px" }}>
|
||||
{useExternalRedeemAddress ? (
|
||||
<MoneroAddressTextField
|
||||
label="External Monero redeem address"
|
||||
address={redeemAddress}
|
||||
onAddressChange={setRedeemAddress}
|
||||
onAddressValidityChange={setRedeemAddressValid}
|
||||
helperText="The monero will be sent to this address if the swap is successful."
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="caption">
|
||||
The Monero will be sent to the internal Monero wallet of the
|
||||
GUI. You can then withdraw them from there or use them for
|
||||
another swap directly.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" style={{}}>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export default function WalletPage() {
|
|||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3">Wallet</Typography>
|
||||
<Alert severity="info">
|
||||
You do not have to deposit money before starting a swap. Instead, you
|
||||
will be greeted with a deposit address after you initiate one.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
import { createTheme, ThemeOptions } from "@mui/material";
|
||||
import { indigo } from "@mui/material/colors";
|
||||
|
||||
// Extend the theme to include custom chip variants
|
||||
declare module "@mui/material/Chip" {
|
||||
interface ChipPropsVariantOverrides {
|
||||
button: true;
|
||||
}
|
||||
}
|
||||
|
||||
// Extend the theme to include custom button variants and sizes
|
||||
declare module "@mui/material/Button" {
|
||||
interface ButtonPropsVariantOverrides {
|
||||
secondary: true;
|
||||
}
|
||||
interface ButtonPropsSizeOverrides {
|
||||
tiny: true;
|
||||
}
|
||||
}
|
||||
|
||||
export enum Theme {
|
||||
Light = "light",
|
||||
Dark = "dark",
|
||||
|
|
@ -33,7 +50,61 @@ const baseTheme: ThemeOptions = {
|
|||
backgroundColor: "color-mix(in srgb, #bdbdbd 10%, transparent)",
|
||||
},
|
||||
},
|
||||
sizeTiny: {
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "4px 8px",
|
||||
minHeight: "24px",
|
||||
minWidth: "auto",
|
||||
lineHeight: 1.2,
|
||||
textTransform: "none",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
props: { variant: "secondary" },
|
||||
style: ({ theme }) => ({
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.08)"
|
||||
: "rgba(0, 0, 0, 0.04)",
|
||||
color: theme.palette.text.secondary,
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.12)"
|
||||
: "rgba(0, 0, 0, 0.08)",
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.23)"
|
||||
: "rgba(0, 0, 0, 0.23)",
|
||||
},
|
||||
"&:disabled": {
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.04)"
|
||||
: "rgba(0, 0, 0, 0.02)",
|
||||
color: theme.palette.text.disabled,
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.08)"
|
||||
: "rgba(0, 0, 0, 0.08)",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
MuiChip: {
|
||||
variants: [
|
||||
{
|
||||
props: { variant: "button" },
|
||||
style: ({ theme }) => ({
|
||||
padding: "12px 16px",
|
||||
cursor: "pointer",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
|
|
|
|||
|
|
@ -31,16 +31,31 @@ import {
|
|||
RedactResponse,
|
||||
GetCurrentSwapResponse,
|
||||
LabeledMoneroAddress,
|
||||
GetPendingApprovalsArgs,
|
||||
GetMoneroHistoryResponse,
|
||||
GetMoneroMainAddressResponse,
|
||||
GetMoneroBalanceResponse,
|
||||
SendMoneroArgs,
|
||||
SendMoneroResponse,
|
||||
GetMoneroSyncProgressResponse,
|
||||
GetPendingApprovalsResponse,
|
||||
RejectApprovalArgs,
|
||||
RejectApprovalResponse,
|
||||
SetRestoreHeightArgs,
|
||||
SetRestoreHeightResponse,
|
||||
GetRestoreHeightResponse,
|
||||
} from "models/tauriModel";
|
||||
import {
|
||||
rpcSetBalance,
|
||||
rpcSetSwapInfo,
|
||||
approvalRequestsReplaced,
|
||||
} from "store/features/rpcSlice";
|
||||
import {
|
||||
setMainAddress,
|
||||
setBalance,
|
||||
setSyncProgress,
|
||||
setHistory,
|
||||
} from "store/features/walletSlice";
|
||||
import { store } from "./store/storeRenderer";
|
||||
import { Maker } from "models/apiModel";
|
||||
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
|
||||
import { MoneroRecoveryResponse } from "models/rpcModel";
|
||||
import { ListSellersResponse } from "../models/tauriModel";
|
||||
|
|
@ -417,6 +432,129 @@ export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse>
|
|||
return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses");
|
||||
}
|
||||
|
||||
export async function getRestoreHeight(): Promise<GetRestoreHeightResponse> {
|
||||
return await invokeNoArgs<GetRestoreHeightResponse>("get_restore_height");
|
||||
}
|
||||
|
||||
export async function setMoneroRestoreHeight(
|
||||
height: number | Date,
|
||||
): Promise<SetRestoreHeightResponse> {
|
||||
const args: SetRestoreHeightArgs =
|
||||
typeof height === "number"
|
||||
? { type: "Height", height: height }
|
||||
: {
|
||||
type: "Date",
|
||||
height: {
|
||||
year: height.getFullYear(),
|
||||
month: height.getMonth() + 1, // JavaScript months are 0-indexed, but we want 1-indexed
|
||||
day: height.getDate(),
|
||||
},
|
||||
};
|
||||
|
||||
return await invoke<SetRestoreHeightArgs, SetRestoreHeightResponse>(
|
||||
"set_monero_restore_height",
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMoneroHistory(): Promise<GetMoneroHistoryResponse> {
|
||||
return await invokeNoArgs<GetMoneroHistoryResponse>("get_monero_history");
|
||||
}
|
||||
|
||||
export async function getMoneroMainAddress(): Promise<GetMoneroMainAddressResponse> {
|
||||
return await invokeNoArgs<GetMoneroMainAddressResponse>(
|
||||
"get_monero_main_address",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMoneroBalance(): Promise<GetMoneroBalanceResponse> {
|
||||
return await invokeNoArgs<GetMoneroBalanceResponse>("get_monero_balance");
|
||||
}
|
||||
|
||||
export async function sendMonero(
|
||||
args: SendMoneroArgs,
|
||||
): Promise<SendMoneroResponse> {
|
||||
return await invoke<SendMoneroArgs, SendMoneroResponse>("send_monero", args);
|
||||
}
|
||||
|
||||
export async function getMoneroSyncProgress(): Promise<GetMoneroSyncProgressResponse> {
|
||||
return await invokeNoArgs<GetMoneroSyncProgressResponse>(
|
||||
"get_monero_sync_progress",
|
||||
);
|
||||
}
|
||||
|
||||
// Wallet management functions that handle Redux dispatching
|
||||
export async function initializeMoneroWallet() {
|
||||
try {
|
||||
const [
|
||||
addressResponse,
|
||||
balanceResponse,
|
||||
syncProgressResponse,
|
||||
historyResponse,
|
||||
] = await Promise.all([
|
||||
getMoneroMainAddress(),
|
||||
getMoneroBalance(),
|
||||
getMoneroSyncProgress(),
|
||||
getMoneroHistory(),
|
||||
]);
|
||||
|
||||
store.dispatch(setMainAddress(addressResponse.address));
|
||||
store.dispatch(setBalance(balanceResponse));
|
||||
store.dispatch(setSyncProgress(syncProgressResponse));
|
||||
store.dispatch(setHistory(historyResponse));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch Monero wallet data:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMoneroTransaction(
|
||||
args: SendMoneroArgs,
|
||||
): Promise<SendMoneroResponse> {
|
||||
try {
|
||||
const response = await sendMonero(args);
|
||||
|
||||
// Refresh balance and history after sending - but don't let this block the response
|
||||
Promise.all([
|
||||
getMoneroBalance(),
|
||||
getMoneroHistory(),
|
||||
]).then(([newBalance, newHistory]) => {
|
||||
store.dispatch(setBalance(newBalance));
|
||||
store.dispatch(setHistory(newHistory));
|
||||
}).catch(refreshErr => {
|
||||
console.error("Failed to refresh wallet data after send:", refreshErr);
|
||||
// Could emit a toast notification here
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error("Failed to send Monero:", err);
|
||||
throw err; // ✅ Re-throw so caller can handle appropriately
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWalletDataAfterTransaction() {
|
||||
try {
|
||||
const [newBalance, newHistory] = await Promise.all([
|
||||
getMoneroBalance(),
|
||||
getMoneroHistory(),
|
||||
]);
|
||||
store.dispatch(setBalance(newBalance));
|
||||
store.dispatch(setHistory(newHistory));
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh wallet data after transaction:", err);
|
||||
// Maybe show a non-blocking notification to user
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMoneroSyncProgress() {
|
||||
try {
|
||||
const response = await getMoneroSyncProgress();
|
||||
store.dispatch(setSyncProgress(response));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch sync progress:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDataDir(): Promise<string> {
|
||||
const testnet = isTestnet();
|
||||
return await invoke<GetDataDirArgs, string>("get_data_dir", {
|
||||
|
|
@ -424,22 +562,37 @@ export async function getDataDir(): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function resolveApproval(
|
||||
export async function resolveApproval<T>(
|
||||
requestId: string,
|
||||
accept: object,
|
||||
accept: T,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
|
||||
"resolve_approval_request",
|
||||
{ request_id: requestId, accept },
|
||||
{ request_id: requestId, accept: accept as object },
|
||||
);
|
||||
} catch (error) {
|
||||
// Refresh approval list when resolve fails to keep UI in sync
|
||||
} finally {
|
||||
// Always refresh the approval list
|
||||
await refreshApprovals();
|
||||
throw error;
|
||||
|
||||
// Refresh the approval list a few miliseconds later to again
|
||||
// Just to make sure :)
|
||||
setTimeout(() => {
|
||||
refreshApprovals();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectApproval<T>(
|
||||
requestId: string,
|
||||
reject: T,
|
||||
): Promise<void> {
|
||||
await invoke<RejectApprovalArgs, RejectApprovalResponse>(
|
||||
"reject_approval_request",
|
||||
{ request_id: requestId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshApprovals(): Promise<void> {
|
||||
const response = await invokeNoArgs<GetPendingApprovalsResponse>(
|
||||
"get_pending_approvals",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import settingsSlice from "./features/settingsSlice";
|
|||
import nodesSlice from "./features/nodesSlice";
|
||||
import conversationsSlice from "./features/conversationsSlice";
|
||||
import poolSlice from "./features/poolSlice";
|
||||
import walletSlice from "./features/walletSlice";
|
||||
|
||||
export const reducers = {
|
||||
swap: swapReducer,
|
||||
|
|
@ -18,4 +19,5 @@ export const reducers = {
|
|||
nodes: nodesSlice,
|
||||
conversations: conversationsSlice,
|
||||
pool: poolSlice,
|
||||
wallet: walletSlice,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -156,6 +156,18 @@ export const rpcSlice = createSlice({
|
|||
backgroundProgressEventRemoved(slice, action: PayloadAction<string>) {
|
||||
delete slice.state.background[action.payload];
|
||||
},
|
||||
rpcSetBackgroundItems(
|
||||
slice,
|
||||
action: PayloadAction<{ [key: string]: TauriBackgroundProgress }>,
|
||||
) {
|
||||
slice.state.background = action.payload;
|
||||
},
|
||||
rpcSetApprovalItems(
|
||||
slice,
|
||||
action: PayloadAction<{ [requestId: string]: ApprovalRequest }>,
|
||||
) {
|
||||
slice.state.approvalRequests = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -175,6 +187,8 @@ export const {
|
|||
approvalRequestsReplaced,
|
||||
backgroundProgressEventReceived,
|
||||
backgroundProgressEventRemoved,
|
||||
rpcSetBackgroundItems,
|
||||
rpcSetApprovalItems,
|
||||
} = rpcSlice.actions;
|
||||
|
||||
export default rpcSlice.reducer;
|
||||
|
|
|
|||
65
src-gui/src/store/features/walletSlice.ts
Normal file
65
src-gui/src/store/features/walletSlice.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import {
|
||||
GetMoneroBalanceResponse,
|
||||
GetMoneroHistoryResponse,
|
||||
GetMoneroSyncProgressResponse,
|
||||
} from "models/tauriModel";
|
||||
|
||||
interface WalletState {
|
||||
// Wallet data
|
||||
mainAddress: string | null;
|
||||
balance: GetMoneroBalanceResponse | null;
|
||||
syncProgress: GetMoneroSyncProgressResponse | null;
|
||||
history: GetMoneroHistoryResponse | null;
|
||||
}
|
||||
|
||||
export interface WalletSlice {
|
||||
state: WalletState;
|
||||
}
|
||||
|
||||
const initialState: WalletSlice = {
|
||||
state: {
|
||||
// Wallet data
|
||||
mainAddress: null,
|
||||
balance: null,
|
||||
syncProgress: null,
|
||||
history: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const walletSlice = createSlice({
|
||||
name: "wallet",
|
||||
initialState,
|
||||
reducers: {
|
||||
// Wallet data actions
|
||||
setMainAddress(slice, action: PayloadAction<string>) {
|
||||
slice.state.mainAddress = action.payload;
|
||||
},
|
||||
setBalance(slice, action: PayloadAction<GetMoneroBalanceResponse>) {
|
||||
slice.state.balance = action.payload;
|
||||
},
|
||||
setSyncProgress(
|
||||
slice,
|
||||
action: PayloadAction<GetMoneroSyncProgressResponse>,
|
||||
) {
|
||||
slice.state.syncProgress = action.payload;
|
||||
},
|
||||
setHistory(slice, action: PayloadAction<GetMoneroHistoryResponse>) {
|
||||
slice.state.history = action.payload;
|
||||
},
|
||||
// Reset actions
|
||||
resetWalletState(slice) {
|
||||
slice.state = initialState.state;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setMainAddress,
|
||||
setBalance,
|
||||
setSyncProgress,
|
||||
setHistory,
|
||||
resetWalletState,
|
||||
} = walletSlice.actions;
|
||||
|
||||
export default walletSlice.reducer;
|
||||
|
|
@ -12,6 +12,10 @@ import {
|
|||
isPendingSelectMakerApprovalEvent,
|
||||
haveFundsBeenLocked,
|
||||
PendingSeedSelectionApprovalRequest,
|
||||
PendingSendMoneroApprovalRequest,
|
||||
isPendingSendMoneroApprovalEvent,
|
||||
PendingPasswordApprovalRequest,
|
||||
isPendingPasswordApprovalEvent,
|
||||
} from "models/tauriModelExt";
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
|
||||
|
|
@ -207,6 +211,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
|
|||
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingSendMoneroApproval(): PendingSendMoneroApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingSendMoneroApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c));
|
||||
|
|
@ -217,6 +226,11 @@ export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalR
|
|||
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingPasswordApproval(): PendingPasswordApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingPasswordApprovalEvent(c));
|
||||
}
|
||||
|
||||
/// Returns all the pending background processes
|
||||
/// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}]
|
||||
export function usePendingBackgroundProcesses(): [
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
updateAllNodeStatuses,
|
||||
fetchSellersAtPresetRendezvousPoints,
|
||||
getSwapInfo,
|
||||
initializeMoneroWallet,
|
||||
} from "renderer/rpc";
|
||||
import logger from "utils/logger";
|
||||
import { contextStatusEventReceived } from "store/features/rpcSlice";
|
||||
|
|
@ -69,6 +70,7 @@ export function createMainListeners() {
|
|||
checkBitcoinBalance(),
|
||||
getAllSwapInfos(),
|
||||
fetchSellersAtPresetRendezvousPoints(),
|
||||
initializeMoneroWallet(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ export function piconerosToXmr(piconeros: number): number {
|
|||
return piconeros / 1000000000000;
|
||||
}
|
||||
|
||||
export function xmrToPiconeros(xmr: number): number {
|
||||
return Math.ceil(xmr * 1000000000000);
|
||||
}
|
||||
|
||||
export function isXmrAddressValid(address: string, stagenet: boolean) {
|
||||
const re = stagenet
|
||||
? "^(?:[57][0-9A-Za-z]{94}|[57][0-9A-Za-z]{105})$"
|
||||
|
|
|
|||
3345
src-gui/yarn.lock
3345
src-gui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue