From 98ac700e0a30b488629f1226864aebee7407e5a8 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Wed, 22 Apr 2026 18:19:41 +0300 Subject: [PATCH] feat(desktop): Feed + Wallet sections (v2.2.0-alpha6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- desktop/package.json | 2 +- desktop/src/lib/api.ts | 87 +++++ desktop/src/lib/feed.ts | 302 ++++++++++++++++++ desktop/src/lib/store.ts | 38 +++ desktop/src/sections/feed/ComposeModal.tsx | 159 +++++++++ desktop/src/sections/feed/FeedPane.tsx | 133 ++++++++ desktop/src/sections/feed/FeedTabs.tsx | 146 +++++++++ desktop/src/sections/feed/PostDetail.tsx | 230 +++++++++++++ desktop/src/sections/feed/PostList.tsx | 93 ++++++ desktop/src/sections/feed/index.tsx | 13 +- desktop/src/sections/wallet/ReceiveModal.tsx | 52 +++ desktop/src/sections/wallet/SendModal.tsx | 219 +++++++++++++ .../src/sections/wallet/WalletDetailPane.tsx | 147 +++++++++ .../src/sections/wallet/WalletOverview.tsx | 222 +++++++++++++ desktop/src/sections/wallet/index.tsx | 49 +-- 15 files changed, 1842 insertions(+), 50 deletions(-) create mode 100644 desktop/src/lib/feed.ts create mode 100644 desktop/src/sections/feed/ComposeModal.tsx create mode 100644 desktop/src/sections/feed/FeedPane.tsx create mode 100644 desktop/src/sections/feed/FeedTabs.tsx create mode 100644 desktop/src/sections/feed/PostDetail.tsx create mode 100644 desktop/src/sections/feed/PostList.tsx create mode 100644 desktop/src/sections/wallet/ReceiveModal.tsx create mode 100644 desktop/src/sections/wallet/SendModal.tsx create mode 100644 desktop/src/sections/wallet/WalletDetailPane.tsx create mode 100644 desktop/src/sections/wallet/WalletOverview.tsx diff --git a/desktop/package.json b/desktop/package.json index e32e8e1..315a955 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "dchain-desktop", - "version": "2.2.0-alpha5", + "version": "2.2.0-alpha6", "description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.", "private": true, "main": "dist-electron/main.js", diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index 6e0200a..7edf6a7 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -112,3 +112,90 @@ export async function getBalance(pub: string): Promise { return r.balance_ut ?? 0; } catch { return 0; } } + +// ─── Wallet / transactions ─────────────────────────────────────────────── + +/** Raw tx row as it appears in /api/address/{pub}.transactions[]. */ +export interface TxRow { + id: string; + type: string; + from: string; + from_addr?: string; + to?: string; + to_addr?: string; + amount_ut: number; + fee_ut: number; + time: string; // ISO-8601 UTC + memo?: string; +} + +interface AddressResponse { + address: string; + pub_key: string; + balance_ut: number; + transactions?: TxRow[]; +} + +/** Full tx detail, matches node/api_explorer.go::apiTxByID shape. */ +export interface TxDetail { + id: string; + type: string; + memo?: string; + from: string; + from_addr?: string; + to?: string; + to_addr?: string; + amount_ut: number; + amount: string; + fee_ut: number; + fee: string; + time: string; + block_index: number; + block_hash: string; + block_time: string; + gas_used?: number; + payload?: unknown; + payload_hex?: string; + signature_hex?: string; +} + +export async function getTxHistory(pub: string, limit = 100): Promise { + try { + const r = await get(`/api/address/${pub}?limit=${limit}`); + return r.transactions ?? []; + } catch { return []; } +} + +export async function getTxDetail(txID: string): Promise { + try { + return await get(`/api/tx/${txID}`); + } catch (e) { + if (/→\s*404\b/.test(String((e as Error).message))) return null; + throw e; + } +} + +/** Resolve a DC address or @username into an Ed25519 pub (hex). */ +export async function resolveAccount(input: string): Promise { + const trimmed = input.trim(); + if (!trimmed) return null; + // Already a hex pub. + if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase(); + // @username — go through the username registry. + if (trimmed.startsWith('@')) { + try { + const r = await get<{ pub_key?: string }>( + `/api/contract/call?id=native:username_registry&method=resolve&arg=${encodeURIComponent(trimmed.slice(1))}`, + ); + return r.pub_key ?? null; + } catch { return null; } + } + // DC… address — ask the explorer. + if (trimmed.startsWith('DC')) { + try { + const r = await get<{ pub_key?: string }>(`/api/address/${trimmed}`); + return r.pub_key ?? null; + } catch { return null; } + } + return null; +} diff --git a/desktop/src/lib/feed.ts b/desktop/src/lib/feed.ts new file mode 100644 index 0000000..de237c9 --- /dev/null +++ b/desktop/src/lib/feed.ts @@ -0,0 +1,302 @@ +// Feed API + tx builders for the desktop client. +// +// Mirrors client-app/lib/feed.ts. Same wire formats on /feed/*, same +// canonical-bytes for tx signatures. The only platform-specific diff +// is the SHA-256 source — we use window.crypto.subtle (Chromium/Electron) +// instead of expo-crypto. + +import { get, getNodeUrl, post } from './api'; +import { + bytesToBase64, bytesToHex, hexToBytes, signBase64, +} from './crypto'; +import { submitTx, type RawTx } from './tx'; + +const MIN_TX_FEE = 1_000; +const _encoder = new TextEncoder(); + +// ─── Types ─────────────────────────────────────────────────────────────── + +export interface FeedPostItem { + post_id: string; + author: string; // hex Ed25519 + content: string; + content_type?: string; + hashtags?: string[]; + reply_to?: string; + quote_of?: string; + created_at: number; // unix seconds + size: number; + hosting_relay: string; + views: number; + likes: number; + has_attachment: boolean; +} + +export interface PostStats { + post_id: string; + views: number; + likes: number; + liked_by_me?: boolean; +} + +export interface PublishResponse { + post_id: string; + hosting_relay: string; + content_hash: string; + size: number; + hashtags: string[]; + estimated_fee_ut: number; +} + +interface TimelineResponse { + count: number; + posts: FeedPostItem[]; +} + +// ─── Reads ─────────────────────────────────────────────────────────────── + +export async function fetchForYou(pub: string, limit = 30): Promise { + const r = await get(`/feed/foryou?pub=${pub}&limit=${limit}`); + return r.posts ?? []; +} + +export async function fetchTrending(windowHours = 24, limit = 30): Promise { + const r = await get(`/feed/trending?window=${windowHours}&limit=${limit}`); + return r.posts ?? []; +} + +export async function fetchAuthorPosts( + pub: string, opts: { limit?: number; before?: number } = {}, +): Promise { + const limit = opts.limit ?? 30; + const qs = opts.before + ? `?limit=${limit}&before=${opts.before}` + : `?limit=${limit}`; + const r = await get(`/feed/author/${pub}${qs}`); + return r.posts ?? []; +} + +export async function fetchTimeline( + followerPub: string, opts: { limit?: number; before?: number } = {}, +): Promise { + const limit = opts.limit ?? 30; + let qs = `?follower=${followerPub}&limit=${limit}`; + if (opts.before) qs += `&before=${opts.before}`; + const r = await get(`/feed/timeline${qs}`); + return r.posts ?? []; +} + +export async function fetchHashtag(tag: string, limit = 30): Promise { + const clean = tag.replace(/^#/, ''); + const r = await get(`/feed/hashtag/${encodeURIComponent(clean)}?limit=${limit}`); + return r.posts ?? []; +} + +export async function fetchPost(postID: string): Promise { + try { return await get(`/feed/post/${postID}`); } + catch (e) { + const m = String((e as Error).message); + if (/→\s*(404|410)\b/.test(m)) return null; + throw e; + } +} + +export async function fetchStats(postID: string, me?: string): Promise { + try { + const path = me + ? `/feed/post/${postID}/stats?me=${me}` + : `/feed/post/${postID}/stats`; + return await get(path); + } catch { + return null; + } +} + +/** Bump the off-chain view counter. Fire-and-forget. */ +export async function bumpView(postID: string): Promise { + try { + await post(`/feed/post/${postID}/view`, undefined); + } catch { /* ignore */ } +} + +// ─── Tx helpers (shared style with lib/tx.ts) ──────────────────────────── + +function rfc3339Now(): string { + const d = new Date(); + d.setMilliseconds(0); + return d.toISOString().replace('.000Z', 'Z'); +} +function newTxID(): string { + return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`; +} +function canonicalBytes(tx: { + id: string; type: string; from: string; to: string; + amount: number; fee: number; payload: string; timestamp: string; +}): Uint8Array { + return _encoder.encode(JSON.stringify({ + id: tx.id, type: tx.type, from: tx.from, to: tx.to, + amount: tx.amount, fee: tx.fee, payload: tx.payload, timestamp: tx.timestamp, + })); +} +function strToBase64(s: string): string { + return bytesToBase64(_encoder.encode(s)); +} + +// ─── SHA-256 via WebCrypto ─────────────────────────────────────────────── + +async function sha256Hex(s: string): Promise { + const buf = await window.crypto.subtle.digest( + 'SHA-256', _encoder.encode(s), + ); + return bytesToHex(new Uint8Array(buf)); +} + +/** 16-byte (32-hex-char) post ID derived from author + entropy + content. */ +async function computePostID(author: string, content: string): Promise { + const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`; + const hex = await sha256Hex(seed); + return hex.slice(0, 32); +} + +// ─── Tx builders ───────────────────────────────────────────────────────── + +export function buildCreatePostTx(p: { + from: string; privKey: string; + postID: string; contentHash: string; size: number; + hostingRelay: string; fee: number; + replyTo?: string; quoteOf?: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify({ + post_id: p.postID, + content_hash: bytesToBase64(hexToBytes(p.contentHash)), + size: p.size, + hosting_relay: p.hostingRelay, + reply_to: p.replyTo ?? '', + quote_of: p.quoteOf ?? '', + })); + const canon = canonicalBytes({ + id, type: 'CREATE_POST', from: p.from, to: '', + amount: 0, fee: p.fee, payload, timestamp, + }); + return { + id, type: 'CREATE_POST', from: p.from, to: '', + amount: 0, fee: p.fee, payload, timestamp, + signature: signBase64(canon, p.privKey), + }; +} + +function simpleTx(type: string, payloadObj: unknown, from: string, to: string, privKey: string): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify(payloadObj)); + const canon = canonicalBytes({ id, type, from, to, amount: 0, fee: MIN_TX_FEE, payload, timestamp }); + return { + id, type, from, to, amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canon, privKey), + }; +} + +export const buildLikePostTx = (p: { from: string; privKey: string; postID: string }) => + simpleTx('LIKE_POST', { post_id: p.postID }, p.from, '', p.privKey); +export const buildUnlikePostTx = (p: { from: string; privKey: string; postID: string }) => + simpleTx('UNLIKE_POST', { post_id: p.postID }, p.from, '', p.privKey); +export const buildDeletePostTx = (p: { from: string; privKey: string; postID: string }) => + simpleTx('DELETE_POST', { post_id: p.postID }, p.from, '', p.privKey); +export const buildFollowTx = (p: { from: string; privKey: string; target: string }) => + simpleTx('FOLLOW', {}, p.from, p.target, p.privKey); +export const buildUnfollowTx = (p: { from: string; privKey: string; target: string }) => + simpleTx('UNFOLLOW', {}, p.from, p.target, p.privKey); + +// ─── Publish flow ──────────────────────────────────────────────────────── + +/** + * POST /feed/publish with a plaintext body, server scrubs image metadata, + * returns the final hosting_relay + content_hash + estimated fee we need + * to commit the matching CREATE_POST tx. + */ +export async function publishPost(p: { + author: string; privKey: string; content: string; + contentType?: string; + attachmentBytes?: Uint8Array; + attachmentMIME?: string; + replyTo?: string; quoteOf?: string; +}): Promise { + const postID = await computePostID(p.author, p.content); + const clientHash = await sha256HexBytes(p.content, p.attachmentBytes); + const ts = Math.floor(Date.now() / 1000); + const sig = signBase64( + _encoder.encode(`publish:${postID}:${clientHash}:${ts}`), + p.privKey, + ); + return post('/feed/publish', { + post_id: postID, + author: p.author, + content: p.content, + content_type: p.contentType ?? 'text/plain', + attachment_b64: p.attachmentBytes ? bytesToBase64(p.attachmentBytes) : undefined, + attachment_mime: p.attachmentMIME, + reply_to: p.replyTo, + quote_of: p.quoteOf, + sig, + ts, + }); +} + +async function sha256HexBytes(content: string, attachment?: Uint8Array): Promise { + const contentBytes = _encoder.encode(content); + const total = new Uint8Array(contentBytes.length + (attachment?.length ?? 0)); + total.set(contentBytes, 0); + if (attachment) total.set(attachment, contentBytes.length); + const buf = await window.crypto.subtle.digest('SHA-256', total); + return bytesToHex(new Uint8Array(buf)); +} + +/** + * Full publish flow: POST /feed/publish → submit matching CREATE_POST tx. + * Returns the committed post_id. + */ +export async function publishAndCommit(p: { + author: string; privKey: string; content: string; + attachmentBytes?: Uint8Array; attachmentMIME?: string; + replyTo?: string; quoteOf?: string; +}): Promise { + const pub = await publishPost(p); + const tx = buildCreatePostTx({ + from: p.author, + privKey: p.privKey, + postID: pub.post_id, + contentHash: pub.content_hash, + size: pub.size, + hostingRelay: pub.hosting_relay, + fee: pub.estimated_fee_ut, + replyTo: p.replyTo, + quoteOf: p.quoteOf, + }); + await submitTx(tx); + return pub.post_id; +} + +// ─── Engagement one-liners ─────────────────────────────────────────────── + +export async function likePost(p: { from: string; privKey: string; postID: string }) { + await submitTx(buildLikePostTx(p)); +} +export async function unlikePost(p: { from: string; privKey: string; postID: string }) { + await submitTx(buildUnlikePostTx(p)); +} +export async function deletePost(p: { from: string; privKey: string; postID: string }) { + await submitTx(buildDeletePostTx(p)); +} +export async function followUser(p: { from: string; privKey: string; target: string }) { + await submitTx(buildFollowTx(p)); +} +export async function unfollowUser(p: { from: string; privKey: string; target: string }) { + await submitTx(buildUnfollowTx(p)); +} + +/** URL for the post's attachment (image / video) — served by the hosting relay. */ +export function attachmentURL(postID: string): string { + return `${getNodeUrl()}/feed/post/${postID}/attachment`; +} diff --git a/desktop/src/lib/store.ts b/desktop/src/lib/store.ts index e2f90ed..ccb1cc5 100644 --- a/desktop/src/lib/store.ts +++ b/desktop/src/lib/store.ts @@ -9,6 +9,26 @@ import type { KeyFile, NodeSettings, Contact, Message } from './types'; export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile'; +/** + * FeedTab is the current filter applied to the Feed section. + * foryou — recommended (unfollowed) posts + * timeline — posts from authors we follow + * trending — top by engagement, last 24h + * hashtag — posts containing a specific tag + * author — wall of a single author + */ +export type FeedTab = + | { kind: 'foryou' } + | { kind: 'timeline' } + | { kind: 'trending' } + | { kind: 'hashtag'; tag: string } + | { kind: 'author'; pub: string }; + +/** Current Wallet selection — either the overview (history) or a tx. */ +export type WalletSelection = + | { kind: 'overview' } + | { kind: 'tx'; id: string }; + interface State { booted: boolean; keyFile: KeyFile | null; @@ -34,6 +54,16 @@ interface State { appendMessage: (addr: string, m: Message) => void; bumpUnread: (addr: string) => void; clearUnread: (addr: string) => void; + + /** Feed state — persists across section switches within the session. */ + feedTab: FeedTab; + feedSelectedPost: string | null; + setFeedTab: (t: FeedTab) => void; + setFeedSelectedPost: (id: string | null) => void; + + /** Wallet state. */ + walletSel: WalletSelection; + setWalletSel: (s: WalletSelection) => void; } export const useStore = create((set) => ({ @@ -81,4 +111,12 @@ export const useStore = create((set) => ({ delete next[addr]; return { unread: next }; }), + + feedTab: { kind: 'foryou' }, + feedSelectedPost: null, + setFeedTab: (t) => set({ feedTab: t, feedSelectedPost: null }), + setFeedSelectedPost: (id) => set({ feedSelectedPost: id }), + + walletSel: { kind: 'overview' }, + setWalletSel: (s) => set({ walletSel: s }), })); diff --git a/desktop/src/sections/feed/ComposeModal.tsx b/desktop/src/sections/feed/ComposeModal.tsx new file mode 100644 index 0000000..75bda34 --- /dev/null +++ b/desktop/src/sections/feed/ComposeModal.tsx @@ -0,0 +1,159 @@ +// ComposeModal — new-post modal reachable from the Feed section header +// or the Ctrl/Cmd+N keybind. Minimal for alpha6: text-only, 4000 char +// limit, no attachments (those come with the image-picker + client-side +// scrub in rc1). Publish flow is identical to mobile — server returns +// content_hash + fee; client commits the matching CREATE_POST tx. + +import React, { useEffect, useMemo, useState } from 'react'; +import { useStore } from '@/lib/store'; +import { publishAndCommit } from '@/lib/feed'; +import { humanizeTxError } from '@/lib/tx'; + +const MAX_CONTENT_LEN = 4000; + +export function ComposeModal({ + onClose, onPublished, +}: { + onClose: () => void; + onPublished: () => void; +}): React.ReactElement { + const keyFile = useStore(s => s.keyFile); + const [content, setContent] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Focus the textarea on mount; close on Escape. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !busy) onClose(); + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { submit(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content, busy]); + + const bytes = useMemo( + () => new TextEncoder().encode(content).length, + [content], + ); + + const hashtags = useMemo(() => { + const m = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) ?? []; + return Array.from(new Set(m.map(t => t.slice(1).toLowerCase()))); + }, [content]); + + const canPublish = !busy && content.trim().length > 0 && bytes <= MAX_CONTENT_LEN; + + const submit = async () => { + if (!keyFile || !canPublish) return; + setBusy(true); setError(null); + try { + await publishAndCommit({ + author: keyFile.pub_key, + privKey: keyFile.priv_key, + content: content.trim(), + }); + onPublished(); + } catch (e) { + setError(humanizeTxError(e)); + } finally { + setBusy(false); + } + }; + + return ( +
!busy && onClose()}> +
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 560, + background: '#0a0a0a', + borderRadius: 16, border: '1px solid #1f1f1f', + padding: 18, + }} + > +
+
+ New post +
+ +
+ +