Desktop client reaches full feature parity with mobile for the two
heaviest sections. Contacts + Devices screen + polish pass remain for
rc1.
Feed section (src/sections/feed/ + src/lib/feed.ts):
* Left pane — FeedTabs: For You / Following / Trending 24h + a
hashtag input that promotes to a tab on Enter; breadcrumb back-
navigation when you drill into an author wall or hashtag.
* Right pane — FeedPane: two sub-columns. Scrollable post list
(truncated body, likes/views/hashtags footer, active highlight)
+ PostDetail with full body, hashtag links (click → hashtag tab),
inline attachment image, like/unlike button, Delete (if mine).
On-mount side-effects: bumpView + fetchStats for liked-by-me.
* ComposeModal — new-post dialog. Ctrl/Cmd+N opens it; Ctrl+Enter
submits. Byte counter against 4000 limit, live hashtag preview.
Uses publishAndCommit (server-side image scrub happens when
attachments land in rc1).
* lib/feed.ts — full mirror of mobile's feed.ts:
fetchForYou/Timeline/Trending/Author/Hashtag/Post/Stats,
bumpView, like/unlike/delete/follow/unfollow, publishPost +
publishAndCommit + buildCreatePostTx. Uses window.crypto.subtle
for SHA-256 (no expo-crypto dep). Same canonical-bytes as mobile.
Wallet section (src/sections/wallet/ + new bits in src/lib/api.ts):
* WalletOverview (left): account card (balance + shortened pub +
Send/Receive/Refresh) and transaction history grouped by row.
Amount colour-codes by direction; pretty tx-type labels.
* WalletDetailPane (right): selected tx — big signed amount,
2-column key/value grid (id, from, to, amount, fee, time, block,
gas), collapsible JSON payload + payload_hex fallback. Mirror of
mobile /tx/[id] layout.
* SendModal — transfer tx with @username / DC-address / hex pub
resolution via resolveAccount. Balance + fee preview; refuses
self-transfer (would roundtrip through mempool for no reason).
* ReceiveModal — pub + Copy button. QR in rc1 once we pull in a
qrcode lib.
* lib/api.ts: TxRow + TxDetail types, getTxHistory, getTxDetail,
resolveAccount (handles hex/@username/DC-address).
Store adds feedTab + feedSelectedPost + walletSel so selection state
survives section-switches. FeedTab discriminated union covers the
hashtag + author sub-states so breadcrumbs know what to render.
Typecheck + renderer build both pass. Node API used as-is — no
server changes in this release.
148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
// WalletDetailPane — right pane of the Wallet section. Either the
|
||
// selected tx's detail or a placeholder when nothing is selected.
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import { useStore } from '@/lib/store';
|
||
import { getTxDetail, type TxDetail } from '@/lib/api';
|
||
import { shortAddr } from '@/lib/crypto';
|
||
|
||
function formatT(ut: number | string): string {
|
||
const n = typeof ut === 'string' ? parseInt(ut, 10) : ut;
|
||
if (!Number.isFinite(n)) return '—';
|
||
return (n / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
||
}
|
||
|
||
export function WalletDetailPane(): React.ReactElement {
|
||
const sel = useStore(s => s.walletSel);
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const [tx, setTx] = useState<TxDetail | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (sel.kind !== 'tx') { setTx(null); return; }
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
getTxDetail(sel.id)
|
||
.then(t => { if (!cancelled) setTx(t); })
|
||
.catch(() => { if (!cancelled) setTx(null); })
|
||
.finally(() => { if (!cancelled) setLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, [sel]);
|
||
|
||
if (sel.kind !== 'tx') {
|
||
return (
|
||
<div style={{
|
||
height: '100%', display: 'flex',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||
}}>
|
||
Pick a transaction from the list on the left to see its details.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading) return <Placeholder note="Loading…" />;
|
||
if (!tx) return <Placeholder note="Transaction not found on this node." />;
|
||
|
||
const outgoing = !!keyFile && tx.from === keyFile.pub_key;
|
||
const amountUT = tx.amount_ut;
|
||
const amountColor = amountUT === 0 ? '#8b8b8b'
|
||
: outgoing ? '#f0b35a' : '#3ba55d';
|
||
|
||
return (
|
||
<div style={{
|
||
height: '100%', overflowY: 'auto',
|
||
padding: '20px 24px', background: '#000',
|
||
}}>
|
||
<div style={{ color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase' }}>
|
||
{tx.type.replace(/_/g, ' ')}
|
||
</div>
|
||
<div style={{
|
||
color: amountColor, fontSize: 30, fontWeight: 800, marginTop: 4,
|
||
}}>
|
||
{amountUT === 0 ? '—' : `${outgoing ? '−' : '+'}${formatT(amountUT)} T`}
|
||
</div>
|
||
{tx.memo && (
|
||
<div style={{ color: '#e0e0e0', fontSize: 13, marginTop: 6, fontStyle: 'italic' }}>
|
||
“{tx.memo}”
|
||
</div>
|
||
)}
|
||
|
||
<div style={{
|
||
marginTop: 22, display: 'grid',
|
||
gridTemplateColumns: 'minmax(120px, auto) 1fr', rowGap: 10, columnGap: 20,
|
||
}}>
|
||
<Cell label="ID">{tx.id}</Cell>
|
||
<Cell label="From">{tx.from_addr ?? shortAddr(tx.from, 8)}</Cell>
|
||
{tx.to && <Cell label="To">{tx.to_addr ?? shortAddr(tx.to, 8)}</Cell>}
|
||
<Cell label="Amount">{formatT(tx.amount_ut)} T</Cell>
|
||
<Cell label="Fee">{formatT(tx.fee_ut)} T</Cell>
|
||
<Cell label="Time">{new Date(tx.time).toLocaleString()}</Cell>
|
||
<Cell label="Block">#{tx.block_index} · {shortAddr(tx.block_hash, 8)}</Cell>
|
||
{typeof tx.gas_used === 'number' && tx.gas_used > 0 && (
|
||
<Cell label="Gas used">{tx.gas_used.toLocaleString()}</Cell>
|
||
)}
|
||
</div>
|
||
|
||
{Boolean(tx.payload) && (
|
||
<details style={{
|
||
marginTop: 22, background: '#0a0a0a',
|
||
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
|
||
}}>
|
||
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
|
||
Payload
|
||
</summary>
|
||
<pre className="selectable" style={{
|
||
marginTop: 8, color: '#d0d0d0', fontSize: 11, lineHeight: 1.5,
|
||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||
}}>
|
||
{JSON.stringify(tx.payload, null, 2)}
|
||
</pre>
|
||
</details>
|
||
)}
|
||
|
||
{tx.payload_hex && (
|
||
<details style={{
|
||
marginTop: 10, background: '#0a0a0a',
|
||
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
|
||
}}>
|
||
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
|
||
Payload (hex)
|
||
</summary>
|
||
<div className="selectable" style={{
|
||
marginTop: 8, color: '#d0d0d0', fontSize: 11, fontFamily: 'monospace',
|
||
wordBreak: 'break-all',
|
||
}}>
|
||
{tx.payload_hex}
|
||
</div>
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<>
|
||
<div style={{
|
||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||
letterSpacing: 1, textTransform: 'uppercase',
|
||
}}>{label}</div>
|
||
<div className="selectable" style={{
|
||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||
wordBreak: 'break-all',
|
||
}}>{children}</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Placeholder({ note }: { note: string }) {
|
||
return (
|
||
<div style={{
|
||
height: '100%', display: 'flex',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
color: '#6a6a6a', fontSize: 13, padding: 40,
|
||
}}>{note}</div>
|
||
);
|
||
}
|