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.
220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
// 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 };
|