diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx index 037004e..bda7cd5 100644 --- a/client-app/app/(app)/_layout.tsx +++ b/client-app/app/(app)/_layout.tsx @@ -36,10 +36,12 @@ export default function AppLayout() { // - chat detail // - compose (new post modal) // - feed sub-routes (post detail, hashtag search) + // - tx detail const hideNav = /^\/chats\/[^/]+/.test(pathname) || pathname === '/compose' || - /^\/feed\/.+/.test(pathname); + /^\/feed\/.+/.test(pathname) || + /^\/tx\/.+/.test(pathname); useBalance(); useContacts(); diff --git a/client-app/app/(app)/tx/[id].tsx b/client-app/app/(app)/tx/[id].tsx new file mode 100644 index 0000000..018e494 --- /dev/null +++ b/client-app/app/(app)/tx/[id].tsx @@ -0,0 +1,427 @@ +/** + * 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} + + + + ); +} diff --git a/client-app/app/(app)/tx/_layout.tsx b/client-app/app/(app)/tx/_layout.tsx new file mode 100644 index 0000000..ee15a2d --- /dev/null +++ b/client-app/app/(app)/tx/_layout.tsx @@ -0,0 +1,19 @@ +/** + * Tx detail layout — native Stack so router.back() pops back to the + * screen that pushed us (wallet history, chat tx link, etc.) instead + * of falling through to the outer Slot-level root. + */ +import React from 'react'; +import { Stack } from 'expo-router'; + +export default function TxLayout() { + return ( + + ); +} diff --git a/client-app/app/(app)/wallet.tsx b/client-app/app/(app)/wallet.tsx index 33c5393..1e45f52 100644 --- a/client-app/app/(app)/wallet.tsx +++ b/client-app/app/(app)/wallet.tsx @@ -17,6 +17,7 @@ import { View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; +import { router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -346,7 +347,7 @@ function TxTile({ const color = toneColor(m.tone); return ( - + router.push(`/(app)/tx/${tx.hash}` as never)}> { + try { + return await get(`/api/tx/${txID}`); + } catch (e: any) { + if (/→\s*404\b/.test(String(e?.message))) return null; + throw e; + } +} + export async function getTxHistory(pubkey: string, limit = 50): Promise { const data = await get(`/api/address/${pubkey}?limit=${limit}`); return (data.transactions ?? []).map(tx => ({