diff --git a/src/popup/History/TransactionDetails.tsx b/src/popup/History/TransactionDetails.tsx new file mode 100644 index 0000000..4b2e3a2 --- /dev/null +++ b/src/popup/History/TransactionDetails.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faXmark, + faCircleCheck, + faCircleXmark, + faExternalLink, + faCopy, +} from '@fortawesome/free-solid-svg-icons'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { Transfer } from './queries'; +import Uik from '@reef-chain/ui-kit'; +import BigNumber from 'bignumber.js'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import { toast } from 'react-toastify'; + +interface TransactionDetailsProps { + transfer: Transfer; + currentAddress: string; + reefscanUrl: string; + isDarkMode: boolean; + onClose: () => void; +} + +/** + * Format token amount with full precision + */ +function formatFullAmount(amount: string, decimals: number): string { + const bn = new BigNumber(amount).div(new BigNumber(10).pow(decimals)); + return bn.toFormat(); +} + +/** + * Format timestamp to full date and time + */ +function formatFullDate(timestamp: string): string { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + }); +} + +/** + * InfoRow component for displaying transaction details + */ +const InfoRow: React.FC<{ + label: string; + value: string; + copyable?: boolean; + isDarkMode: boolean; +}> = ({ label, value, copyable = false, isDarkMode }) => ( +
+ +
+ + {copyable && ( + toast.success('Copied to clipboard!')} + > + + + )} +
+
+); + +/** + * TransactionDetails Modal Component + * Shows detailed information about a transaction + */ +export const TransactionDetails: React.FC = ({ + transfer, + currentAddress, + reefscanUrl, + isDarkMode, + onClose, +}) => { + const isSent = transfer.from.toLowerCase() === currentAddress.toLowerCase(); + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+ + +
+ + {/* Content */} +
+ {/* Status Badge */} +
+
+ + +
+
+ + {/* Amount */} +
+ + +
+ + + + {/* Details */} +
+ + + + + + + + + + + + + + + + + +
+ + {/* View on Explorer Button */} + + } + className="w-full" + /> + +
+
+ + ); +}; diff --git a/src/popup/History/TransactionHistory.tsx b/src/popup/History/TransactionHistory.tsx new file mode 100644 index 0000000..d7e2756 --- /dev/null +++ b/src/popup/History/TransactionHistory.tsx @@ -0,0 +1,255 @@ +import React, { useState, useContext } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faHistory, + faRefresh, + faSpinner, + faExclamationTriangle, +} from '@fortawesome/free-solid-svg-icons'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import Uik from '@reef-chain/ui-kit'; +import { useTheme } from '../context/ThemeContext'; +import ReefSigners from '../context/ReefSigners'; +import { useTransactionHistory } from './useTransactionHistory'; +import { TransactionItem } from './TransactionItem'; +import { TransactionDetails } from './TransactionDetails'; +import { Transfer } from './queries'; +import strings from '../../i18n/locales'; + +/** + * Empty State Component + */ +const EmptyState: React.FC<{ isDarkMode: boolean }> = ({ isDarkMode }) => ( +
+ + + +
+); + +/** + * Error State Component + */ +const ErrorState: React.FC<{ + error: string; + onRetry: () => void; + isDarkMode: boolean; +}> = ({ error, onRetry, isDarkMode }) => ( +
+ + + + } + /> +
+); + +/** + * Loading Skeleton Component + */ +const LoadingSkeleton: React.FC<{ isDarkMode: boolean }> = ({ isDarkMode }) => ( +
+ {[...Array(5)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+); + +/** + * Transaction History Component + * Main component that displays the transaction history page + */ +const TransactionHistory: React.FC = () => { + const { isDarkMode } = useTheme(); + const { accounts, network } = useContext(ReefSigners); + + const [selectedTransfer, setSelectedTransfer] = useState(null); + + // Get selected account address + const selectedAccount = accounts?.find(acc => !!(acc as any).isSelected); + const currentAddress = selectedAccount?.address; + + // Get GraphQL URL from network config + const graphqlUrl = network?.graphqlExplorerUrl || 'https://squid.subsquid.io/reef-explorer/graphql'; + const reefscanUrl = network?.reefscanUrl || 'https://reefscan.com'; + + // Fetch transaction history + const { + transfers, + loading, + error, + hasMore, + loadMore, + refresh, + } = useTransactionHistory(graphqlUrl, currentAddress); + + // Handle transaction click + const handleTransactionClick = (transfer: Transfer) => { + setSelectedTransfer(transfer); + }; + + return ( +
+ {/* Header */} +
+
+
+ + +
+ + +
+
+ + {/* Content */} +
+ {/* No address selected */} + {!currentAddress && ( +
+ +
+ )} + + {/* Loading initial data */} + {loading && transfers.length === 0 && } + + {/* Error state */} + {error && !loading && ( + + )} + + {/* Empty state */} + {!loading && !error && transfers.length === 0 && currentAddress && ( + + )} + + {/* Transaction List */} + {transfers.length > 0 && ( + <> +
+ {transfers.map((transfer) => ( + handleTransactionClick(transfer)} + /> + ))} +
+ + {/* Load More Button */} + {hasMore && ( +
+ + ) : undefined + } + /> +
+ )} + + {/* End of list message */} + {!hasMore && transfers.length > 0 && ( +
+ +
+ )} + + )} +
+ + {/* Transaction Details Modal */} + {selectedTransfer && ( + setSelectedTransfer(null)} + /> + )} +
+ ); +}; + +export default TransactionHistory; diff --git a/src/popup/History/TransactionItem.tsx b/src/popup/History/TransactionItem.tsx new file mode 100644 index 0000000..e5e4517 --- /dev/null +++ b/src/popup/History/TransactionItem.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faArrowUp, + faArrowDown, + faCircleCheck, + faCircleXmark, + faExternalLink, +} from '@fortawesome/free-solid-svg-icons'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { Transfer } from './queries'; +import Uik from '@reef-chain/ui-kit'; +import BigNumber from 'bignumber.js'; + +interface TransactionItemProps { + transfer: Transfer; + currentAddress: string; + reefscanUrl: string; + isDarkMode: boolean; + onClick: () => void; +} + +/** + * Truncate address to show first and last characters + * @example "5F3sa2TJ...5EYjQX" + */ +function truncateAddress(address: string, start: number = 6, end: number = 6): string { + if (address.length <= start + end) return address; + return `${address.slice(0, start)}...${address.slice(-end)}`; +} + +/** + * Format timestamp to readable date + * @example "Dec 22, 2025 10:30 PM" + */ +function formatDate(timestamp: string): string { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +/** + * Format token amount with proper decimals + * @example "1,234.56 REEF" + */ +function formatAmount(amount: string, decimals: number, symbol: string): string { + const bn = new BigNumber(amount).div(new BigNumber(10).pow(decimals)); + return `${bn.toFormat(2)} ${symbol}`; +} + +/** + * TransactionItem Component + * Displays a single transaction/transfer in the history list + */ +export const TransactionItem: React.FC = ({ + transfer, + currentAddress, + reefscanUrl, + isDarkMode, + onClick, +}) => { + const isSent = transfer.from.toLowerCase() === currentAddress.toLowerCase(); + const isReceived = transfer.to.toLowerCase() === currentAddress.toLowerCase(); + + const otherAddress = isSent ? transfer.to : transfer.from; + const direction = isSent ? 'Sent' : 'Received'; + const directionColor = isSent ? 'text-red-500' : 'text-green-500'; + const directionIcon = isSent ? faArrowUp : faArrowDown; + + return ( +
+
+ {/* Left section: Icon + Details */} +
+ {/* Direction Icon */} +
+ +
+ + {/* Transaction Details */} +
+
+ + +
+ +
+ + + {/* Success/Failed badge */} + +
+ + +
+
+ + {/* Right section: Amount + Link */} + +
+
+ ); +}; diff --git a/src/popup/History/queries.ts b/src/popup/History/queries.ts new file mode 100644 index 0000000..0205ca5 --- /dev/null +++ b/src/popup/History/queries.ts @@ -0,0 +1,199 @@ +// GraphQL queries for Reef blockchain transaction history via Subsquid + +export interface Transfer { + id: string; + blockNumber: number; + timestamp: string; + extrinsicHash: string; + from: string; + to: string; + token: { + id: string; + name: string; + symbol: string; + decimals: number; + }; + amount: string; + success: boolean; + type: 'EVM' | 'Native'; +} + +export interface Extrinsic { + id: string; + hash: string; + blockNumber: number; + timestamp: string; + signer: string; + method: string; + section: string; + args: string; + success: boolean; + fee: string; +} + +export interface TransactionHistoryResponse { + transfers: Transfer[]; + extrinsics: Extrinsic[]; +} + +/** + * GraphQL query to fetch transfer history for an address + * Queries both incoming and outgoing transfers + */ +export const TRANSFER_HISTORY_QUERY = ` + query GetTransferHistory( + $address: String! + $limit: Int! + $offset: Int! + ) { + sentTransfers: transfers( + where: { from_eq: $address } + orderBy: timestamp_DESC + limit: $limit + offset: $offset + ) { + id + blockNumber + timestamp + extrinsicHash + from + to + token { + id + name + symbol + decimals + } + amount + success + type + } + receivedTransfers: transfers( + where: { to_eq: $address } + orderBy: timestamp_DESC + limit: $limit + offset: $offset + ) { + id + blockNumber + timestamp + extrinsicHash + from + to + token { + id + name + symbol + decimals + } + amount + success + type + } + } +`; + +/** + * GraphQL query to fetch extrinsic (transaction) history for an address + */ +export const EXTRINSIC_HISTORY_QUERY = ` + query GetExtrinsicHistory( + $address: String! + $limit: Int! + $offset: Int! + ) { + extrinsics( + where: { signer_eq: $address } + orderBy: timestamp_DESC + limit: $limit + offset: $offset + ) { + id + hash + blockNumber + timestamp + signer + method + section + args + success + fee + } + } +`; + +/** + * Fetch transaction history from Reef GraphQL API + */ +export async function fetchTransactionHistory( + graphqlUrl: string, + address: string, + limit: number = 20, + offset: number = 0 +): Promise { + try { + // Fetch transfers + const transferResponse = await fetch(graphqlUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: TRANSFER_HISTORY_QUERY, + variables: { address, limit, offset }, + }), + }); + + if (!transferResponse.ok) { + throw new Error(`Transfer query failed: ${transferResponse.statusText}`); + } + + const transferData = await transferResponse.json(); + + if (transferData.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(transferData.errors)}`); + } + + // Combine sent and received transfers + const sentTransfers = transferData.data?.sentTransfers || []; + const receivedTransfers = transferData.data?.receivedTransfers || []; + const allTransfers = [...sentTransfers, ...receivedTransfers]; + + // Remove duplicates and sort by timestamp + const uniqueTransfers = Array.from( + new Map(allTransfers.map((t) => [t.id, t])).values() + ).sort((a, b) => { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + }); + + // Fetch extrinsics + const extrinsicResponse = await fetch(graphqlUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: EXTRINSIC_HISTORY_QUERY, + variables: { address, limit, offset }, + }), + }); + + if (!extrinsicResponse.ok) { + throw new Error(`Extrinsic query failed: ${extrinsicResponse.statusText}`); + } + + const extrinsicData = await extrinsicResponse.json(); + + if (extrinsicData.errors) { + console.error('GraphQL errors:', extrinsicData.errors); + } + + return { + transfers: uniqueTransfers.slice(0, limit), + extrinsics: extrinsicData.data?.extrinsics || [], + }; + } catch (error) { + console.error('Error fetching transaction history:', error); + throw error; + } +} diff --git a/src/popup/History/useTransactionHistory.ts b/src/popup/History/useTransactionHistory.ts new file mode 100644 index 0000000..9984580 --- /dev/null +++ b/src/popup/History/useTransactionHistory.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback } from 'react'; +import { fetchTransactionHistory, Transfer, Extrinsic } from './queries'; + +export interface TransactionHistoryState { + transfers: Transfer[]; + extrinsics: Extrinsic[]; + loading: boolean; + error: string | null; + hasMore: boolean; +} + +export interface UseTransactionHistoryResult extends TransactionHistoryState { + loadMore: () => void; + refresh: () => void; +} + +const ITEMS_PER_PAGE = 20; + +/** + * React hook to fetch and manage transaction history + * + * @param graphqlUrl - GraphQL endpoint URL (mainnet or testnet) + * @param address - Blockchain address to query transactions for + * @returns Transaction history state and actions + * + * @example + * const { transfers, loading, error, loadMore } = useTransactionHistory( + * 'https://squid.subsquid.io/reef-explorer/graphql', + * '5F3sa2TJAWMqDhXG6jhV4N8ko9SxwGy8TpaNS1repo5EYjQX' + * ); + */ +export function useTransactionHistory( + graphqlUrl: string, + address: string | undefined +): UseTransactionHistoryResult { + const [state, setState] = useState({ + transfers: [], + extrinsics: [], + loading: false, + error: null, + hasMore: true, + }); + + const [offset, setOffset] = useState(0); + + const fetchData = useCallback( + async (currentOffset: number, append: boolean = false) => { + if (!address || !graphqlUrl) { + setState(prev => ({ + ...prev, + loading: false, + error: 'No address or GraphQL URL provided', + })); + return; + } + + setState(prev => ({ ...prev, loading: true, error: null })); + + try { + const data = await fetchTransactionHistory( + graphqlUrl, + address, + ITEMS_PER_PAGE, + currentOffset + ); + + setState(prev => ({ + transfers: append + ? [...prev.transfers, ...data.transfers] + : data.transfers, + extrinsics: append + ? [...prev.extrinsics, ...data.extrinsics] + : data.extrinsics, + loading: false, + error: null, + hasMore: data.transfers.length === ITEMS_PER_PAGE, + })); + } catch (error) { + setState(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Failed to fetch transaction history', + })); + } + }, + [graphqlUrl, address] + ); + + // Initial load + useEffect(() => { + setOffset(0); + fetchData(0, false); + }, [fetchData]); + + // Load more transactions + const loadMore = useCallback(() => { + if (state.loading || !state.hasMore) return; + + const newOffset = offset + ITEMS_PER_PAGE; + setOffset(newOffset); + fetchData(newOffset, true); + }, [offset, state.loading, state.hasMore, fetchData]); + + // Refresh from beginning + const refresh = useCallback(() => { + setOffset(0); + fetchData(0, false); + }, [fetchData]); + + return { + ...state, + loadMore, + refresh, + }; +} diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index 1ffbeac..11522db 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -15,6 +15,7 @@ import { faLanguage, faSun, faMoon, + faHistory, } from "@fortawesome/free-solid-svg-icons"; import "./popup.css"; @@ -73,6 +74,7 @@ import { faThemeco } from "@fortawesome/free-brands-svg-icons"; import { useReefSigners } from "./hooks/useReefSigners"; import Tokens from "./Tokens/Tokens"; import VDA from "./VDA/VDA"; +import TransactionHistory from "./History/TransactionHistory"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const accountToReefSigner = async ( @@ -381,6 +383,12 @@ const Popup = () => { onClick={() => _onAction("/vda")} />} + {selectedNetwork && _onAction("/history")} + />} {!location.pathname.startsWith("/account/") && ( { path="/vda" element={} /> + } + /> } diff --git a/tsconfig.json b/tsconfig.json index 70325a6..f169032 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,9 @@ "module": "es6", "target": "es6", "moduleResolution": "node", - "esModuleInterop": true + "esModuleInterop": true, + "skipLibCheck": true, + "noEmitOnError": false }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"]