Files
dchain/desktop/src/sections/feed/FeedPane.tsx
vsecoder 98ac700e0a 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.
2026-04-22 18:19:41 +03:00

134 lines
4.2 KiB
TypeScript

// FeedPane — the right pane. A two-column split: scrollable post list
// on the left (~430px), thread/post detail on the right.
import React, { useCallback, useEffect, useState } from 'react';
import { useStore, type FeedTab } from '@/lib/store';
import {
fetchForYou, fetchTrending, fetchTimeline, fetchHashtag, fetchAuthorPosts,
type FeedPostItem,
} from '@/lib/feed';
import { PostList } from './PostList';
import { PostDetail } from './PostDetail';
import { ComposeModal } from './ComposeModal';
export function FeedPane(): React.ReactElement {
const tab = useStore(s => s.feedTab);
const selected = useStore(s => s.feedSelectedPost);
const keyFile = useStore(s => s.keyFile);
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [loading, setLoading] = useState(true);
const [composing, setComposing] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const list = await fetchByTab(tab, keyFile?.pub_key);
setPosts(list);
} catch {
setPosts([]);
} finally {
setLoading(false);
}
}, [tab, keyFile]);
useEffect(() => { load(); }, [load]);
// Ctrl/Cmd+N → compose (scoped to Feed being active).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'n') {
e.preventDefault();
setComposing(true);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<div style={{ height: '100%', display: 'flex' }}>
<div style={{
width: 430, flexShrink: 0, borderRight: '1px solid #1f1f1f',
overflowY: 'auto', background: '#000',
}}>
{/* Header strip — tab label + compose CTA */}
<div style={{
position: 'sticky', top: 0, zIndex: 1,
padding: '10px 14px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #1f1f1f',
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
}}>
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
{titleFor(tab)}
</div>
<button
onClick={() => setComposing(true)}
style={{
padding: '6px 12px', borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}
title="Ctrl/Cmd+N"
>New post</button>
</div>
{loading ? (
<div style={{
padding: 40, textAlign: 'center', color: '#6a6a6a', fontSize: 13,
}}>Loading</div>
) : posts.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center', color: '#6a6a6a', fontSize: 13,
}}>No posts in this feed yet.</div>
) : (
<PostList posts={posts} activeID={selected} />
)}
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<PostDetail postID={selected} onDeleted={load} />
</div>
{composing && keyFile && (
<ComposeModal
onClose={() => setComposing(false)}
onPublished={() => {
setComposing(false);
// Re-pull so the new post shows up immediately.
setTimeout(load, 800);
}}
/>
)}
</div>
);
}
function titleFor(tab: FeedTab): string {
switch (tab.kind) {
case 'foryou': return 'For You';
case 'timeline': return 'Following';
case 'trending': return 'Trending 24h';
case 'hashtag': return `#${tab.tag}`;
case 'author': return 'Author wall';
}
}
async function fetchByTab(tab: FeedTab, selfPub: string | undefined): Promise<FeedPostItem[]> {
switch (tab.kind) {
case 'foryou':
if (!selfPub) return fetchTrending(24, 30);
return fetchForYou(selfPub, 30);
case 'timeline':
if (!selfPub) return [];
return fetchTimeline(selfPub, { limit: 30 });
case 'trending':
return fetchTrending(24, 30);
case 'hashtag':
return fetchHashtag(tab.tag, 30);
case 'author':
return fetchAuthorPosts(tab.pub, { limit: 30 });
}
}