feat(client): transaction detail screen (wallet history → tap to inspect)
Users can now tap any row in the wallet history and see the full
transaction detail, matching what the block explorer shows for the
same tx. Covers every visible activity — transfers, contact
requests, likes, posts, follows, relay proofs, contract calls.
Components
lib/api.ts
- New TxDetail interface mirroring node/api_explorer.go's
txDetail JSON (id, type, from/to + their DC addresses, µT
amount + display string, fee, block coords, gas, payload,
signature hex).
- getTxDetail(txID) with 404→null handling.
app/(app)/tx/[id].tsx — new screen
- Hero row: icon + type label + local-time timestamp
- Big amount pill (only for txs that move tokens) — signed by
the viewer's perspective (+ when you received, − when you
paid, neutral when it's someone else's tx or a non-transfer)
- Info card rows with tap-to-copy on hashes and addresses:
Tx ID, From (highlighted "you" when it's the signed-in user),
To (same), Block, Fee, Gas used (when > 0), Memo (when set)
- Collapsible Payload section — renders JSON with 2-space
indent if the node could decode it, otherwise the raw hex
- Signature copy row at the bottom (useful for debugging / audits)
- txMeta() covers all EventTypes from blockchain/types.go
(TRANSFER, CONTACT_REQUEST/ACCEPT/BLOCK, REGISTER_KEY/RELAY,
BIND_WALLET, RELAY_PROOF, BLOCK_REWARD, HEARTBEAT, CREATE_POST,
DELETE_POST, LIKE_POST/UNLIKE_POST, FOLLOW/UNFOLLOW,
CALL_CONTRACT, DEPLOY_CONTRACT, STAKE/UNSTAKE) with
distinct icons + in/out/neutral tone.
- Nested Stack layout so router.back() pops to the caller;
safeBack() fallback when entered via deep link.
app/(app)/wallet.tsx
- TxTile's outer Pressable was a no-op onPress handler; now
router.push(`/(app)/tx/${tx.hash}`). Entire row is the
touch target (icon + type + addr + time + amount).
app/(app)/_layout.tsx
- /tx/* added to hideNav regex so the detail screen is
full-screen without the 5-icon bar at the bottom.
Translation quirk
The screen is English to match the rest of the UI (what the user
just asked for in the previous commit). Handles copying via
expo-clipboard — tapping an address/hash shows "Copied" for 1.5s
with a green check, then reverts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
427
client-app/app/(app)/tx/[id].tsx
Normal file
427
client-app/app/(app)/tx/[id].tsx
Normal file
@@ -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] <TYPE>
|
||||
* <relative time> · <status>
|
||||
*
|
||||
* [amount pill, big, signed ± + tone colour] (for TRANSFER-ish)
|
||||
*
|
||||
* Info card rows:
|
||||
* ID <hash> (tap → copy)
|
||||
* From <addr> (tap → copy)
|
||||
* To <addr> (tap → copy)
|
||||
* Block #N
|
||||
* Time <human>
|
||||
* 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<typeof Ionicons>['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<TxDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(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 (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Transaction"
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={{ padding: 24 }}>
|
||||
<Text style={{ color: '#f4212e', fontSize: 14 }}>{error}</Text>
|
||||
</View>
|
||||
) : !tx ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||
<Ionicons name="help-circle-outline" size={40} color="#3a3a3a" />
|
||||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||||
Not found
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6 }}>
|
||||
No transaction with this ID on this chain.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||
{/* ── Hero row: icon + type + time ───────────────────────── */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 14,
|
||||
backgroundColor: '#111111',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name={meta!.icon} size={22} color="#ffffff" />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
|
||||
{meta!.label}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
{new Date(tx.time).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Amount big number — only for txs that move tokens ── */}
|
||||
{showAmount && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: amountColor,
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.8,
|
||||
}}>
|
||||
{sign}{formatAmount(tx.amount_ut)}
|
||||
</Text>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
{tx.amount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Info card ───────────────────────────────────────────── */}
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CopyRow
|
||||
label="Tx ID"
|
||||
value={shortAddr(tx.id, 8)}
|
||||
rawValue={tx.id}
|
||||
field="id"
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
mono
|
||||
/>
|
||||
<Divider />
|
||||
<CopyRow
|
||||
label="From"
|
||||
value={shortAddr(tx.from, 8)}
|
||||
rawValue={tx.from}
|
||||
field="from"
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
mono
|
||||
highlight={tx.from === mine ? 'you' : undefined}
|
||||
/>
|
||||
{tx.to && (
|
||||
<>
|
||||
<Divider />
|
||||
<CopyRow
|
||||
label="To"
|
||||
value={shortAddr(tx.to, 8)}
|
||||
rawValue={tx.to}
|
||||
field="to"
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
mono
|
||||
highlight={tx.to === mine ? 'you' : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<InfoRow label="Block" value={`#${tx.block_index}`} />
|
||||
<Divider />
|
||||
<InfoRow label="Fee" value={formatAmount(tx.fee_ut)} />
|
||||
{tx.gas_used ? (
|
||||
<>
|
||||
<Divider />
|
||||
<InfoRow label="Gas used" value={String(tx.gas_used)} />
|
||||
</>
|
||||
) : null}
|
||||
{tx.memo ? (
|
||||
<>
|
||||
<Divider />
|
||||
<InfoRow label="Memo" value={tx.memo} />
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* ── Payload expand ─────────────────────────────────────── */}
|
||||
{(tx.payload || tx.payload_hex) && (
|
||||
<View style={{ marginTop: 14 }}>
|
||||
<Pressable
|
||||
onPress={() => setPayloadOpen(o => !o)}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: pressed ? '#0f0f0f' : '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
borderRadius: 14,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="code-slash" size={14} color="#8b8b8b" />
|
||||
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600', marginLeft: 8, flex: 1 }}>
|
||||
Payload
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={payloadOpen ? 'chevron-up' : 'chevron-down'}
|
||||
size={14}
|
||||
color="#6a6a6a"
|
||||
/>
|
||||
</Pressable>
|
||||
{payloadOpen && (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#050505',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={{
|
||||
color: '#d0d0d0',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: 16,
|
||||
}}
|
||||
>
|
||||
{tx.payload
|
||||
? JSON.stringify(tx.payload, null, 2)
|
||||
: `hex: ${tx.payload_hex}`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Signature as a final copyable row, small */}
|
||||
{tx.signature_hex && (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CopyRow
|
||||
label="Signature"
|
||||
value={shortAddr(tx.signature_hex, 10)}
|
||||
rawValue={tx.signature_hex}
|
||||
field="signature"
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
mono
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rows ──────────────────────────────────────────────────────────────
|
||||
|
||||
function Divider() {
|
||||
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={() => onCopy(field, rawValue)}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: isCopied
|
||||
? '#3ba55d'
|
||||
: highlight === 'you'
|
||||
? '#1d9bf0'
|
||||
: '#ffffff',
|
||||
fontSize: 13,
|
||||
fontFamily: mono ? 'monospace' : undefined,
|
||||
fontWeight: '600',
|
||||
marginRight: 8,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{isCopied
|
||||
? 'Copied'
|
||||
: highlight === 'you'
|
||||
? `${value} (you)`
|
||||
: value}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isCopied ? 'checkmark' : 'copy-outline'}
|
||||
size={13}
|
||||
color={isCopied ? '#3ba55d' : '#6a6a6a'}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
19
client-app/app/(app)/tx/_layout.tsx
Normal file
19
client-app/app/(app)/tx/_layout.tsx
Normal file
@@ -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 (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Pressable>
|
||||
<Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user