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 { getMarkup, piconerosToXmr, satsToBtc } from "utils/conversionUtils";
@ -10,12 +10,18 @@ export function AmountWithUnit({
fixedPrecision,
exchangeRate,
parenthesisText = null,
labelStyles,
amountStyles,
disableTooltip = false,
}: {
amount: Amount;
unit: string;
fixedPrecision: number;
exchangeRate?: Amount;
parenthesisText?: string;
labelStyles?: SxProps;
amountStyles?: SxProps;
disableTooltip?: boolean;
}) {
const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [
settings.fetchFiatPrices,
@ -29,12 +35,25 @@ export function AmountWithUnit({
? `${(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 (
<Tooltip arrow title={title}>
<span>
{amount != null ? amount.toFixed(fixedPrecision) : "?"} {unit}
{parenthesisText != null ? ` (${parenthesisText})` : null}
</span>
{content}
</Tooltip>
);
}
@ -89,9 +108,15 @@ export function BitcoinAmount({ amount }: { amount: Amount }) {
export function MoneroAmount({
amount,
fixedPrecision = 4,
labelStyles,
amountStyles,
disableTooltip = false,
}: {
amount: Amount;
fixedPrecision?: number;
labelStyles?: SxProps;
amountStyles?: SxProps;
disableTooltip?: boolean;
}) {
const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
@ -101,6 +126,9 @@ export function MoneroAmount({
unit="XMR"
fixedPrecision={fixedPrecision}
exchangeRate={xmrRate}
labelStyles={labelStyles}
amountStyles={amountStyles}
disableTooltip={disableTooltip}
/>
);
}
@ -164,14 +192,23 @@ export function SatsAmount({ amount }: { amount: Amount }) {
export function PiconeroAmount({
amount,
fixedPrecision = 8,
labelStyles,
amountStyles,
disableTooltip = false,
}: {
amount: Amount;
fixedPrecision?: number;
labelStyles?: SxProps;
amountStyles?: SxProps;
disableTooltip?: boolean;
}) {
return (
<MoneroAmount
amount={amount == null ? null : piconerosToXmr(amount)}
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 {
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 { Typography, Box, Paper } from "@mui/material";
import { TransactionInfo } from "models/tauriModel";
import _ from "lodash";
import dayjs from "dayjs";
import TransactionItem from "./TransactionItem";
interface TransactionHistoryProps {
history?: {
@ -27,76 +10,52 @@ interface TransactionHistoryProps {
};
}
interface TransactionGroup {
date: string;
displayDate: string;
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">Transactions</Typography>;
}
return (
<>
<Typography variant="h5">Transaction History</Typography>
const transactions = history.transactions;
<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>
// Group transactions by date using dayjs and lodash
const transactionGroups: TransactionGroup[] = _(transactions)
.groupBy((tx) => dayjs(tx.timestamp * 1000).format("YYYY-MM-DD")) // Convert Unix timestamp to date string
.map((txs, dateKey) => ({
date: dateKey,
displayDate: dayjs(dateKey).format("MMMM D, YYYY"), // Human-readable format
transactions: _.orderBy(txs, ["timestamp"], ["desc"]), // Sort transactions within group by newest first
}))
.orderBy(["date"], ["desc"]) // Sort groups by newest date first
.value();
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Transactions
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 6 }}>
{transactionGroups.map((group) => (
<Box key={group.date}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
{group.displayDate}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{group.transactions.map((tx) => (
<TransactionItem key={tx.tx_hash} transaction={tx} />
))}
</TableBody>
</Table>
</TableContainer>
</>
</Box>
</Box>
))}
</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
amount={parseFloat(balance.unlocked_balance)}
fixedPrecision={4}
disableTooltip
/>
</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 {
Light = "light",
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: {
@ -142,6 +166,14 @@ const darkTheme = createTheme({
main: "#f4511e", // Monero orange
},
secondary: indigo,
error: {
main: "#f44336",
tint: "#e58686",
},
success: {
main: "#4caf50",
tint: "#70c491",
},
},
});
@ -153,6 +185,14 @@ const lightTheme = createTheme({
main: "#f4511e", // Monero orange
},
secondary: indigo,
error: {
main: "#f44336",
tint: "#ff5252",
},
success: {
main: "#4caf50",
tint: "#4caf50",
},
},
});