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:
@@ -112,3 +112,90 @@ export async function getBalance(pub: string): Promise<number> {
|
||||
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<TxRow[]> {
|
||||
try {
|
||||
const r = await get<AddressResponse>(`/api/address/${pub}?limit=${limit}`);
|
||||
return r.transactions ?? [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
|
||||
try {
|
||||
return await get<TxDetail>(`/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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
302
desktop/src/lib/feed.ts
Normal file
302
desktop/src/lib/feed.ts
Normal file
@@ -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<FeedPostItem[]> {
|
||||
const r = await get<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
|
||||
const r = await get<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchAuthorPosts(
|
||||
pub: string, opts: { limit?: number; before?: number } = {},
|
||||
): Promise<FeedPostItem[]> {
|
||||
const limit = opts.limit ?? 30;
|
||||
const qs = opts.before
|
||||
? `?limit=${limit}&before=${opts.before}`
|
||||
: `?limit=${limit}`;
|
||||
const r = await get<TimelineResponse>(`/feed/author/${pub}${qs}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTimeline(
|
||||
followerPub: string, opts: { limit?: number; before?: number } = {},
|
||||
): Promise<FeedPostItem[]> {
|
||||
const limit = opts.limit ?? 30;
|
||||
let qs = `?follower=${followerPub}&limit=${limit}`;
|
||||
if (opts.before) qs += `&before=${opts.before}`;
|
||||
const r = await get<TimelineResponse>(`/feed/timeline${qs}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
|
||||
const clean = tag.replace(/^#/, '');
|
||||
const r = await get<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(clean)}?limit=${limit}`);
|
||||
return r.posts ?? [];
|
||||
}
|
||||
|
||||
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
|
||||
try { return await get<FeedPostItem>(`/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<PostStats | null> {
|
||||
try {
|
||||
const path = me
|
||||
? `/feed/post/${postID}/stats?me=${me}`
|
||||
: `/feed/post/${postID}/stats`;
|
||||
return await get<PostStats>(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bump the off-chain view counter. Fire-and-forget. */
|
||||
export async function bumpView(postID: string): Promise<void> {
|
||||
try {
|
||||
await post<unknown>(`/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<string> {
|
||||
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<string> {
|
||||
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<PublishResponse> {
|
||||
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<PublishResponse>('/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<string> {
|
||||
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<string> {
|
||||
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`;
|
||||
}
|
||||
@@ -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<State>((set) => ({
|
||||
@@ -81,4 +111,12 @@ export const useStore = create<State>((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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user