/** * Transaction detail screen — shows everything the block explorer * does for a single tx, so the user can audit any action they took * (transfer, post, like, contact request) without leaving the app. * * Route: /(app)/tx/[id] * * Triggered from: wallet history (TxTile tap). Will also be reachable * from post detail / profile timestamp once we wire those up (Phase * v2.1 idea). * * Layout matches the style of the profile info card: * [back] Transaction * * [ICON] * · * * [amount pill, big, signed ± + tone colour] (for TRANSFER-ish) * * Info card rows: * ID (tap → copy) * From (tap → copy) * To (tap → copy) * Block #N * Time * Fee 0.001 T * Gas 1234 (if CALL_CONTRACT) * Memo (if set) * * [payload section, collapsible — raw JSON or hex] */ import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, ScrollView, ActivityIndicator, Pressable, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; import { getTxDetail, type TxDetail } from '@/lib/api'; import { useStore } from '@/lib/store'; import { safeBack, formatAmount } from '@/lib/utils'; function shortAddr(a: string, n = 8): string { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } // Copy of the tx-type metadata used by wallet.tsx — keeps the icon + // label consistent whichever screen surfaces the tx. function txMeta(type: string): { icon: React.ComponentProps['name']; label: string; tone: 'in' | 'out' | 'neutral'; } { switch (type) { case 'TRANSFER': return { icon: 'swap-horizontal', label: 'Transfer', tone: 'neutral' }; case 'CONTACT_REQUEST': return { icon: 'person-add', label: 'Contact request', tone: 'out' }; case 'ACCEPT_CONTACT': return { icon: 'checkmark-circle', label: 'Accepted contact', tone: 'neutral' }; case 'BLOCK_CONTACT': return { icon: 'ban', label: 'Blocked contact', tone: 'neutral' }; case 'REGISTER_KEY': return { icon: 'key', label: 'Identity registered', tone: 'neutral' }; case 'REGISTER_RELAY': return { icon: 'globe', label: 'Relay registered', tone: 'neutral' }; case 'BIND_WALLET': return { icon: 'wallet', label: 'Wallet bound', tone: 'neutral' }; case 'RELAY_PROOF': return { icon: 'receipt', label: 'Relay proof', tone: 'in' }; case 'BLOCK_REWARD': return { icon: 'trophy', label: 'Block reward', tone: 'in' }; case 'HEARTBEAT': return { icon: 'pulse', label: 'Heartbeat', tone: 'neutral' }; case 'CREATE_POST': return { icon: 'newspaper', label: 'Post published', tone: 'out' }; case 'DELETE_POST': return { icon: 'trash', label: 'Post deleted', tone: 'neutral' }; case 'LIKE_POST': return { icon: 'heart', label: 'Like', tone: 'neutral' }; case 'UNLIKE_POST': return { icon: 'heart-dislike', label: 'Unlike', tone: 'neutral' }; case 'FOLLOW': return { icon: 'person-add', label: 'Follow', tone: 'neutral' }; case 'UNFOLLOW': return { icon: 'person-remove', label: 'Unfollow', tone: 'neutral' }; case 'CALL_CONTRACT': return { icon: 'terminal', label: 'Contract call', tone: 'neutral' }; case 'DEPLOY_CONTRACT': return { icon: 'cube', label: 'Contract deployed', tone: 'neutral' }; case 'STAKE': return { icon: 'lock-closed', label: 'Stake', tone: 'out' }; case 'UNSTAKE': return { icon: 'lock-open', label: 'Unstake', tone: 'in' }; default: return { icon: 'document', label: type || 'Transaction', tone: 'neutral' }; } } function toneColor(tone: 'in' | 'out' | 'neutral'): string { if (tone === 'in') return '#3ba55d'; if (tone === 'out') return '#f4212e'; return '#ffffff'; } export default function TxDetailScreen() { const insets = useSafeAreaInsets(); const { id } = useLocalSearchParams<{ id: string }>(); const keyFile = useStore(s => s.keyFile); const [tx, setTx] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(null); const [payloadOpen, setPayloadOpen] = useState(false); useEffect(() => { if (!id) return; let cancelled = false; setLoading(true); setError(null); getTxDetail(id) .then(res => { if (!cancelled) setTx(res); }) .catch(e => { if (!cancelled) setError(String(e?.message ?? e)); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [id]); const copy = useCallback(async (field: string, value: string) => { await Clipboard.setStringAsync(value); setCopied(field); setTimeout(() => setCopied(f => (f === field ? null : f)), 1500); }, []); const meta = tx ? txMeta(tx.type) : null; const mine = keyFile?.pub_key ?? ''; const isMineOut = tx ? tx.from === mine && tx.to !== mine : false; const isMineIn = tx ? tx.to === mine && tx.from !== mine : false; const showAmount = tx ? tx.amount_ut > 0 : false; // Sign based on perspective: money leaving my wallet → minus, coming in → plus. const sign = isMineOut ? '−' : isMineIn ? '+' : ''; const amountColor = isMineOut ? '#f4212e' : isMineIn ? '#3ba55d' : '#ffffff'; return (
safeBack()} />} /> {loading ? ( ) : error ? ( {error} ) : !tx ? ( Not found No transaction with this ID on this chain. ) : ( {/* ── Hero row: icon + type + time ───────────────────────── */} {meta!.label} {new Date(tx.time).toLocaleString()} {/* ── Amount big number — only for txs that move tokens ── */} {showAmount && ( {sign}{formatAmount(tx.amount_ut)} {tx.amount} )} {/* ── Info card ───────────────────────────────────────────── */} {tx.to && ( <> )} {tx.gas_used ? ( <> ) : null} {tx.memo ? ( <> ) : null} {/* ── Payload expand ─────────────────────────────────────── */} {(tx.payload || tx.payload_hex) && ( setPayloadOpen(o => !o)} style={({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 14, backgroundColor: pressed ? '#0f0f0f' : '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f', borderRadius: 14, })} > Payload {payloadOpen && ( {tx.payload ? JSON.stringify(tx.payload, null, 2) : `hex: ${tx.payload_hex}`} )} )} {/* Signature as a final copyable row, small */} {tx.signature_hex && ( )} )} ); } // ── Rows ────────────────────────────────────────────────────────────── function Divider() { return ; } function InfoRow({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } function CopyRow({ label, value, rawValue, field, copied, onCopy, mono, highlight, }: { label: string; value: string; rawValue: string; field: string; copied: string | null; onCopy: (field: string, value: string) => void; mono?: boolean; highlight?: 'you'; }) { const isCopied = copied === field; return ( onCopy(field, rawValue)} style={({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, backgroundColor: pressed ? '#0f0f0f' : 'transparent', })} > {label} {isCopied ? 'Copied' : highlight === 'you' ? `${value} (you)` : value} ); }