feat(gui): Rework transaction history ui (#470)

Co-authored-by: b-enedict <benedict.seuss@gmail.com>
This commit is contained in:
Mohan 2025-07-18 15:33:59 +02:00 committed by GitHub
parent c1c45571f0
commit 1ad4bcadf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 331 additions and 89 deletions

@ -1 +1 @@
Subproject commit dbbccecc89e1121762a4ad6b531638ece82aa0c7 Subproject commit 5f714f147fd29228698070e6bd80e41ce2f86fb0

View file

@ -1,4 +1,4 @@
import { Tooltip } from "@mui/material"; import { Box, SxProps, Tooltip, Typography } from "@mui/material";
import { useAppSelector, useSettings } from "store/hooks"; import { useAppSelector, useSettings } from "store/hooks";
import { getMarkup, piconerosToXmr, satsToBtc } from "utils/conversionUtils"; import { getMarkup, piconerosToXmr, satsToBtc } from "utils/conversionUtils";
@ -10,12 +10,18 @@ export function AmountWithUnit({
fixedPrecision, fixedPrecision,
exchangeRate, exchangeRate,
parenthesisText = null, parenthesisText = null,
labelStyles,
amountStyles,
disableTooltip = false,
}: { }: {
amount: Amount; amount: Amount;
unit: string; unit: string;
fixedPrecision: number; fixedPrecision: number;
exchangeRate?: Amount; exchangeRate?: Amount;
parenthesisText?: string; parenthesisText?: string;
labelStyles?: SxProps;
amountStyles?: SxProps;
disableTooltip?: boolean;
}) { }) {
const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [ const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [
settings.fetchFiatPrices, settings.fetchFiatPrices,
@ -29,12 +35,25 @@ export function AmountWithUnit({
? `${(exchangeRate * amount).toFixed(2)} ${fiatCurrency}` ? `${(exchangeRate * amount).toFixed(2)} ${fiatCurrency}`
: ""; : "";
const content = (
<span>
<Box sx={{ display: "inline", ...amountStyles }}>
{amount != null ? amount.toFixed(fixedPrecision) : "?"}
</Box>{" "}
<Box sx={{ display: "inline", ...labelStyles }}>
{unit}
{parenthesisText != null ? ` (${parenthesisText})` : null}
</Box>
</span>
);
if (disableTooltip) {
return content;
}
return ( return (
<Tooltip arrow title={title}> <Tooltip arrow title={title}>
<span> {content}
{amount != null ? amount.toFixed(fixedPrecision) : "?"} {unit}
{parenthesisText != null ? ` (${parenthesisText})` : null}
</span>
</Tooltip> </Tooltip>
); );
} }
@ -89,9 +108,15 @@ export function BitcoinAmount({ amount }: { amount: Amount }) {
export function MoneroAmount({ export function MoneroAmount({
amount, amount,
fixedPrecision = 4, fixedPrecision = 4,
labelStyles,
amountStyles,
disableTooltip = false,
}: { }: {
amount: Amount; amount: Amount;
fixedPrecision?: number; fixedPrecision?: number;
labelStyles?: SxProps;
amountStyles?: SxProps;
disableTooltip?: boolean;
}) { }) {
const xmrRate = useAppSelector((state) => state.rates.xmrPrice); const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
@ -101,6 +126,9 @@ export function MoneroAmount({
unit="XMR" unit="XMR"
fixedPrecision={fixedPrecision} fixedPrecision={fixedPrecision}
exchangeRate={xmrRate} exchangeRate={xmrRate}
labelStyles={labelStyles}
amountStyles={amountStyles}
disableTooltip={disableTooltip}
/> />
); );
} }
@ -164,14 +192,23 @@ export function SatsAmount({ amount }: { amount: Amount }) {
export function PiconeroAmount({ export function PiconeroAmount({
amount, amount,
fixedPrecision = 8, fixedPrecision = 8,
labelStyles,
amountStyles,
disableTooltip = false,
}: { }: {
amount: Amount; amount: Amount;
fixedPrecision?: number; fixedPrecision?: number;
labelStyles?: SxProps;
amountStyles?: SxProps;
disableTooltip?: boolean;
}) { }) {
return ( return (
<MoneroAmount <MoneroAmount
amount={amount == null ? null : piconerosToXmr(amount)} amount={amount == null ? null : piconerosToXmr(amount)}
fixedPrecision={fixedPrecision} fixedPrecision={fixedPrecision}
labelStyles={labelStyles}
amountStyles={amountStyles}
disableTooltip={disableTooltip}
/> />
); );
} }

View file

@ -0,0 +1,50 @@
import { Box, Chip, Tooltip, Typography } from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
CheckCircleOutline as CheckCircleOutlineIcon,
} from "@mui/icons-material";
export default function ConfirmationsBadge({
confirmations,
}: {
confirmations: number;
}) {
if (confirmations === 0) {
return (
<Chip
icon={<AutoAwesomeIcon />}
label="Published"
color="secondary"
size="small"
/>
);
} else if (confirmations < 10) {
const label = (
<>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "end",
gap: 0.4,
}}
>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{confirmations}
</Typography>
<Typography variant="caption">/10</Typography>
</Box>
</>
);
return <Chip label={label} color="warning" size="small" />;
} else {
return (
<Tooltip title={`${confirmations} Confirmations`}>
<CheckCircleOutlineIcon
sx={{ color: "text.secondary" }}
fontSize="small"
/>
</Tooltip>
);
}
}

View file

@ -1,25 +1,8 @@
import { import { Typography, Box, Paper } from "@mui/material";
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"; import { TransactionInfo } from "models/tauriModel";
import _ from "lodash";
import dayjs from "dayjs";
import TransactionItem from "./TransactionItem";
interface TransactionHistoryProps { interface TransactionHistoryProps {
history?: { history?: {
@ -27,76 +10,52 @@ interface TransactionHistoryProps {
}; };
} }
interface TransactionGroup {
date: string;
displayDate: string;
transactions: TransactionInfo[];
}
// Component for displaying transaction history // Component for displaying transaction history
export default function TransactionHistory({ export default function TransactionHistory({
history, history,
}: TransactionHistoryProps) { }: TransactionHistoryProps) {
if (!history || !history.transactions || history.transactions.length === 0) { if (!history || !history.transactions || history.transactions.length === 0) {
return <Typography variant="h5">Transaction History</Typography>; return <Typography variant="h5">Transactions</Typography>;
} }
return ( const transactions = history.transactions;
<>
<Typography variant="h5">Transaction History</Typography>
<TableContainer component={Paper} variant="outlined"> // Group transactions by date using dayjs and lodash
<Table size="small"> const transactionGroups: TransactionGroup[] = _(transactions)
<TableHead> .groupBy((tx) => dayjs(tx.timestamp * 1000).format("YYYY-MM-DD")) // Convert Unix timestamp to date string
<TableRow> .map((txs, dateKey) => ({
<TableCell>Amount</TableCell> date: dateKey,
<TableCell>Fee</TableCell> displayDate: dayjs(dateKey).format("MMMM D, YYYY"), // Human-readable format
<TableCell align="right">Confirmations</TableCell> transactions: _.orderBy(txs, ["timestamp"], ["desc"]), // Sort transactions within group by newest first
<TableCell align="center">Explorer</TableCell> }))
</TableRow> .orderBy(["date"], ["desc"]) // Sort groups by newest date first
</TableHead> .value();
<TableBody>
{[...history.transactions] return (
.sort((a, b) => a.confirmations - b.confirmations) <Box>
.map((tx, index) => ( <Typography variant="h5" sx={{ mb: 2 }}>
<TableRow key={index}> Transactions
<TableCell> </Typography>
<Stack direction="row" spacing={1} alignItems="center"> <Box sx={{ display: "flex", flexDirection: "column", gap: 6 }}>
<PiconeroAmount amount={tx.amount} /> {transactionGroups.map((group) => (
<Chip <Box key={group.date}>
label={tx.direction === "In" ? "Received" : "Sent"} <Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
color={tx.direction === "In" ? "success" : "default"} {group.displayDate}
size="small" </Typography>
/> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
</Stack> {group.transactions.map((tx) => (
</TableCell> <TransactionItem key={tx.tx_hash} transaction={tx} />
<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> </Box>
</Table> </Box>
</TableContainer> ))}
</> </Box>
</Box>
); );
} }

View file

@ -0,0 +1,155 @@
import {
Box,
Chip,
IconButton,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { TransactionDirection, TransactionInfo } from "models/tauriModel";
import {
CallReceived as IncomingIcon,
MoreVert as MoreVertIcon,
} from "@mui/icons-material";
import { CallMade as OutgoingIcon } from "@mui/icons-material";
import {
FiatPiconeroAmount,
PiconeroAmount,
} from "renderer/components/other/Units";
import ConfirmationsBadge from "./ConfirmationsBadge";
import { getMoneroTxExplorerUrl } from "utils/conversionUtils";
import { isTestnet } from "store/config";
import { open } from "@tauri-apps/plugin-shell";
import dayjs from "dayjs";
import { useState } from "react";
interface TransactionItemProps {
transaction: TransactionInfo;
}
export default function TransactionItem({ transaction }: TransactionItemProps) {
const isIncoming = transaction.direction === TransactionDirection.In;
const displayDate = dayjs(transaction.timestamp * 1000).format(
"MMM DD YYYY, HH:mm",
);
const amountStyles = isIncoming
? { color: "success.tint" }
: { color: "error.tint" };
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(menuAnchorEl);
return (
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
<Box
sx={{
p: 0.5,
backgroundColor: "grey.800",
borderRadius: "100%",
height: 40,
aspectRatio: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{isIncoming ? <IncomingIcon /> : <OutgoingIcon />}
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: "min-content max-content",
columnGap: 0.5,
}}
>
<Typography
variant="h6"
sx={{
opacity: !isIncoming ? 1 : 0,
gridArea: "1 / 1",
fontWeight: "bold",
...amountStyles,
}}
>
</Typography>
<Typography
variant="h6"
sx={{ gridArea: "1 / 2", fontWeight: "bold", ...amountStyles }}
>
<PiconeroAmount
amount={transaction.amount}
labelStyles={{ fontSize: 14, ml: -0.3 }}
disableTooltip
/>
</Typography>
<Typography variant="caption" sx={{ gridArea: "2 / 2" }}>
<FiatPiconeroAmount amount={transaction.amount} />
</Typography>
</Box>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
<Typography
variant="body1"
color="text.secondary"
sx={{ fontSize: 14 }}
>
{displayDate}
</Typography>
<ConfirmationsBadge confirmations={transaction.confirmations} />
<IconButton
onClick={(event) => {
setMenuAnchorEl(event.currentTarget);
}}
>
<MoreVertIcon />
</IconButton>
<Menu
anchorEl={menuAnchorEl}
open={menuOpen}
onClose={() => setMenuAnchorEl(null)}
>
<MenuItem
onClick={() => {
navigator.clipboard.writeText(transaction.tx_hash);
setMenuAnchorEl(null);
}}
>
<Typography>Copy Transaction ID</Typography>
</MenuItem>
<MenuItem
onClick={() => {
open(getMoneroTxExplorerUrl(transaction.tx_hash, isTestnet()));
setMenuAnchorEl(null);
}}
>
<Typography>View on Explorer</Typography>
</MenuItem>
</Menu>
</Box>
</Box>
);
}

View file

@ -72,6 +72,7 @@ export default function WalletOverview({
<PiconeroAmount <PiconeroAmount
amount={parseFloat(balance.unlocked_balance)} amount={parseFloat(balance.unlocked_balance)}
fixedPrecision={4} fixedPrecision={4}
disableTooltip
/> />
</Typography> </Typography>
<Typography <Typography

View file

@ -18,6 +18,17 @@ declare module "@mui/material/Button" {
} }
} }
// Extend the palette to include custom color properties
declare module "@mui/material/styles" {
interface PaletteColor {
tint?: string;
}
interface PaletteColorOptions {
tint?: string;
}
}
export enum Theme { export enum Theme {
Light = "light", Light = "light",
Dark = "dark", Dark = "dark",
@ -93,6 +104,19 @@ const baseTheme: ThemeOptions = {
}, },
}), }),
}, },
{
props: { size: "tiny" },
style: {
fontSize: "0.75rem",
fontWeight: 500,
padding: "4px 8px",
minHeight: "24px",
minWidth: "auto",
lineHeight: 1.2,
textTransform: "none",
borderRadius: "4px",
},
},
], ],
}, },
MuiChip: { MuiChip: {
@ -142,6 +166,14 @@ const darkTheme = createTheme({
main: "#f4511e", // Monero orange main: "#f4511e", // Monero orange
}, },
secondary: indigo, secondary: indigo,
error: {
main: "#f44336",
tint: "#e58686",
},
success: {
main: "#4caf50",
tint: "#70c491",
},
}, },
}); });
@ -153,6 +185,14 @@ const lightTheme = createTheme({
main: "#f4511e", // Monero orange main: "#f4511e", // Monero orange
}, },
secondary: indigo, secondary: indigo,
error: {
main: "#f44336",
tint: "#ff5252",
},
success: {
main: "#4caf50",
tint: "#4caf50",
},
}, },
}); });