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.
134 lines
4.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|