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:
vsecoder
2026-04-22 18:19:41 +03:00
parent ce11a13874
commit 98ac700e0a
15 changed files with 1842 additions and 50 deletions

View 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 };