feat(desktop): Feed + Wallet sections (v2.2.0-alpha6)
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.
This commit is contained in:
219
desktop/src/sections/wallet/SendModal.tsx
Normal file
219
desktop/src/sections/wallet/SendModal.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// SendModal — a focused little dialog for Transfer tx's. Accepts a
|
||||
// hex pub, DC-address, or @username and resolves to the Ed25519 pub
|
||||
// before submitting. Validates amount against balance + min fee.
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getBalance, resolveAccount } from '@/lib/api';
|
||||
import { buildTransferTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||
|
||||
const MIN_FEE_UT = 1_000;
|
||||
|
||||
function parseAmountT(s: string): number | null {
|
||||
const n = parseFloat(s);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.round(n * 1_000_000);
|
||||
}
|
||||
|
||||
export function SendModal({
|
||||
onClose, onSent,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [toInput, setToInput] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||
}, [keyFile]);
|
||||
|
||||
const amountUT = useMemo(() => parseAmountT(amount), [amount]);
|
||||
const totalUT = amountUT === null ? null : amountUT + MIN_FEE_UT;
|
||||
|
||||
const canSend = !!keyFile && !busy && amountUT !== null
|
||||
&& balance !== null && totalUT !== null && balance >= totalUT
|
||||
&& toInput.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!keyFile || !canSend || amountUT === null) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const to = await resolveAccount(toInput);
|
||||
if (!to) throw new Error('Can\'t resolve recipient');
|
||||
if (to === keyFile.pub_key) throw new Error('Refusing self-transfer');
|
||||
const tx = buildTransferTx({
|
||||
from: keyFile.pub_key,
|
||||
to,
|
||||
amount: amountUT,
|
||||
fee: MIN_FEE_UT,
|
||||
privKey: keyFile.priv_key,
|
||||
memo: memo.trim() || undefined,
|
||||
});
|
||||
await submitTx(tx);
|
||||
onSent();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Backdrop onClose={busy ? () => {} : onClose}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<Header title="Send" onClose={onClose} busy={busy} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Field label="To" hint="@username, DC-address or hex pubkey">
|
||||
<input
|
||||
value={toInput}
|
||||
onChange={e => setToInput(e.target.value)}
|
||||
placeholder="@alice or DC… or <hex>"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Amount (T)">
|
||||
<input
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
inputMode="decimal"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>
|
||||
Balance: {balance === null ? '…' : `${(balance / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 })} T`}
|
||||
{amountUT !== null && (
|
||||
<> · Fee: {(MIN_FEE_UT / 1_000_000).toFixed(6)} T</>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Memo (optional)">
|
||||
<input
|
||||
value={memo}
|
||||
onChange={e => setMemo(e.target.value)}
|
||||
placeholder="Invoice #42"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={secondaryBtnStyle(busy)}
|
||||
>Cancel</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
style={primaryBtnStyle(!canSend)}
|
||||
>{busy ? '…' : 'Send'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared modal primitives used by Send/Receive ────────────────────────
|
||||
|
||||
function Backdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, onClose, busy }: {
|
||||
title: string; onClose: () => void; busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: {
|
||||
label: string; hint?: string; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
|
||||
}}>{label}</div>
|
||||
{children}
|
||||
{hint && (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>{hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||||
padding: '9px 18px', borderRadius: 999, border: 'none',
|
||||
background: '#1d9bf0', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
});
|
||||
const secondaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
});
|
||||
|
||||
export { Backdrop, Header, Field, inputStyle, primaryBtnStyle, secondaryBtnStyle };
|
||||
Reference in New Issue
Block a user