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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dchain-desktop",
|
"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.",
|
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
|
|||||||
@@ -112,3 +112,90 @@ export async function getBalance(pub: string): Promise<number> {
|
|||||||
return r.balance_ut ?? 0;
|
return r.balance_ut ?? 0;
|
||||||
} catch { return 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';
|
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 {
|
interface State {
|
||||||
booted: boolean;
|
booted: boolean;
|
||||||
keyFile: KeyFile | null;
|
keyFile: KeyFile | null;
|
||||||
@@ -34,6 +54,16 @@ interface State {
|
|||||||
appendMessage: (addr: string, m: Message) => void;
|
appendMessage: (addr: string, m: Message) => void;
|
||||||
bumpUnread: (addr: string) => void;
|
bumpUnread: (addr: string) => void;
|
||||||
clearUnread: (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) => ({
|
export const useStore = create<State>((set) => ({
|
||||||
@@ -81,4 +111,12 @@ export const useStore = create<State>((set) => ({
|
|||||||
delete next[addr];
|
delete next[addr];
|
||||||
return { unread: next };
|
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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
159
desktop/src/sections/feed/ComposeModal.tsx
Normal file
159
desktop/src/sections/feed/ComposeModal.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 20,
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}} onClick={() => !busy && onClose()}>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: 560,
|
||||||
|
background: '#0a0a0a',
|
||||||
|
borderRadius: 16, border: '1px solid #1f1f1f',
|
||||||
|
padding: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
|
||||||
|
New post
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={content}
|
||||||
|
onChange={e => setContent(e.target.value)}
|
||||||
|
placeholder="What's happening?"
|
||||||
|
rows={6}
|
||||||
|
style={{
|
||||||
|
width: '100%', resize: 'vertical',
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 10, padding: '12px',
|
||||||
|
color: '#fff', fontSize: 14, fontFamily: 'inherit',
|
||||||
|
outline: 'none', lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'space-between', gap: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11 }}>
|
||||||
|
{bytes.toLocaleString()} / {MAX_CONTENT_LEN.toLocaleString()} bytes
|
||||||
|
{hashtags.length > 0 && (
|
||||||
|
<> · <span style={{ color: '#1d9bf0' }}>
|
||||||
|
{hashtags.slice(0, 3).map(t => `#${t}`).join(' ')}
|
||||||
|
</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #1f1f1f',
|
||||||
|
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: busy ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!canPublish}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 999,
|
||||||
|
border: 'none', background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: canPublish ? 'pointer' : 'default',
|
||||||
|
opacity: canPublish ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>{busy ? '…' : 'Publish'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: 10, borderRadius: 8,
|
||||||
|
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||||
|
}}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
desktop/src/sections/feed/FeedPane.tsx
Normal file
133
desktop/src/sections/feed/FeedPane.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
146
desktop/src/sections/feed/FeedTabs.tsx
Normal file
146
desktop/src/sections/feed/FeedTabs.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// FeedTabs — left-pane navigation for the Feed section.
|
||||||
|
//
|
||||||
|
// Four top-level tabs (For You / Following / Trending / Hashtag) plus
|
||||||
|
// an inline hashtag input that promotes to a dedicated tab when you
|
||||||
|
// press Enter. Sub-states — viewing a specific author's wall — are
|
||||||
|
// reachable by clicking an @handle in the post list; a breadcrumb
|
||||||
|
// appears at the top for back-navigation.
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStore, type FeedTab } from '@/lib/store';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
interface TabOption {
|
||||||
|
kind: FeedTab['kind'];
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_TABS: TabOption[] = [
|
||||||
|
{ kind: 'foryou', label: 'For You', hint: 'Recommended posts from authors you don\'t follow yet' },
|
||||||
|
{ kind: 'timeline', label: 'Following', hint: 'Posts from authors you follow' },
|
||||||
|
{ kind: 'trending', label: 'Trending 24h', hint: 'Top posts by engagement in the last day' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FeedTabs(): React.ReactElement {
|
||||||
|
const tab = useStore(s => s.feedTab);
|
||||||
|
const setTab = useStore(s => s.setFeedTab);
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
|
||||||
|
// Sub-tab renderers reachable from list items (author wall, hashtag tab).
|
||||||
|
// Instead of hiding them in a dropdown we surface a breadcrumb so the
|
||||||
|
// operator can jump back out cleanly.
|
||||||
|
const breadcrumb = renderBreadcrumb(tab, setTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 10 }}>
|
||||||
|
{breadcrumb}
|
||||||
|
|
||||||
|
{STATIC_TABS.map(t => (
|
||||||
|
<TabRow
|
||||||
|
key={t.kind}
|
||||||
|
label={t.label}
|
||||||
|
hint={t.hint}
|
||||||
|
active={tab.kind === t.kind}
|
||||||
|
onClick={() => setTab({ kind: t.kind } as FeedTab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Hashtag input — promotes to a tab on Enter. */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, padding: 10, borderRadius: 10,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 6,
|
||||||
|
}}>Hashtag</div>
|
||||||
|
<input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={e => setTagInput(e.target.value.replace(/^#/, ''))}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && tagInput.trim().length > 0) {
|
||||||
|
setTab({ kind: 'hashtag', tag: tagInput.trim() });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="type a tag…"
|
||||||
|
style={{
|
||||||
|
width: '100%', background: '#000',
|
||||||
|
border: '1px solid #1f1f1f', borderRadius: 8,
|
||||||
|
padding: '8px 10px', color: '#fff', fontSize: 13,
|
||||||
|
fontFamily: 'monospace', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabRow({
|
||||||
|
label, hint, active, onClick,
|
||||||
|
}: {
|
||||||
|
label: string; hint: string; active: boolean; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
border: active ? '1px solid #1d9bf022' : '1px solid transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
color: active ? '#1d9bf0' : '#fff',
|
||||||
|
fontSize: 14, fontWeight: 700,
|
||||||
|
}}>{label}</div>
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2, lineHeight: 1.4 }}>
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBreadcrumb(tab: FeedTab, setTab: (t: FeedTab) => void): React.ReactNode | null {
|
||||||
|
if (tab.kind === 'hashtag') {
|
||||||
|
return (
|
||||||
|
<Breadcrumb
|
||||||
|
label={`#${tab.tag}`}
|
||||||
|
onClear={() => setTab({ kind: 'foryou' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tab.kind === 'author') {
|
||||||
|
return (
|
||||||
|
<Breadcrumb
|
||||||
|
label={`Author: ${shortAddr(tab.pub, 6)}`}
|
||||||
|
onClear={() => setTab({ kind: 'foryou' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Breadcrumb({ label, onClear }: { label: string; onClear: () => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '8px 10px', marginBottom: 10,
|
||||||
|
borderRadius: 8, background: '#0a0a0a',
|
||||||
|
border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', color: '#8b8b8b',
|
||||||
|
cursor: 'pointer', padding: 0, fontSize: 14,
|
||||||
|
}}
|
||||||
|
>←</button>
|
||||||
|
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700, flex: 1 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
desktop/src/sections/feed/PostDetail.tsx
Normal file
230
desktop/src/sections/feed/PostDetail.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// PostDetail — right-hand-inner pane showing a single post with full
|
||||||
|
// body, attachment, engagement bar, delete-if-mine.
|
||||||
|
//
|
||||||
|
// Side effects on mount:
|
||||||
|
// * bumps the view counter (off-chain)
|
||||||
|
// * refreshes stats for the liked-by-me badge
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
fetchPost, fetchStats, bumpView, likePost, unlikePost, deletePost,
|
||||||
|
attachmentURL, type FeedPostItem, type PostStats,
|
||||||
|
} from '@/lib/feed';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { humanizeTxError } from '@/lib/tx';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
postID: string | null;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostDetail({ postID, onDeleted }: Props): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setTab = useStore(s => s.setFeedTab);
|
||||||
|
|
||||||
|
const [post, setPost] = useState<FeedPostItem | null>(null);
|
||||||
|
const [stats, setStats] = useState<PostStats | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load + side-effects.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!postID) { setPost(null); setStats(null); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
fetchPost(postID).then(p => { if (!cancelled) setPost(p); }).catch(() => {});
|
||||||
|
fetchStats(postID, keyFile?.pub_key).then(s => { if (!cancelled) setStats(s); }).catch(() => {});
|
||||||
|
bumpView(postID);
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [postID, keyFile?.pub_key]);
|
||||||
|
|
||||||
|
const toggleLike = useCallback(async () => {
|
||||||
|
if (!keyFile || !post || busy) return;
|
||||||
|
const liked = stats?.liked_by_me ?? false;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
// Optimistic — roll back if the tx fails.
|
||||||
|
setStats(s => s ? { ...s, liked_by_me: !liked, likes: s.likes + (liked ? -1 : 1) } : s);
|
||||||
|
try {
|
||||||
|
if (liked) await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||||
|
else await likePost ({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||||
|
} catch (e) {
|
||||||
|
setStats(s => s ? { ...s, liked_by_me: liked, likes: s.likes + (liked ? 1 : -1) } : s);
|
||||||
|
setError(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, post, stats, busy]);
|
||||||
|
|
||||||
|
const onDelete = useCallback(async () => {
|
||||||
|
if (!keyFile || !post || busy) return;
|
||||||
|
if (!confirm('Delete this post? This cannot be undone.')) return;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
await deletePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
|
||||||
|
onDeleted();
|
||||||
|
useStore.getState().setFeedSelectedPost(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [keyFile, post, busy, onDeleted]);
|
||||||
|
|
||||||
|
if (!postID) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Select a post from the list on the left.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#6a6a6a', fontSize: 13,
|
||||||
|
}}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mine = !!keyFile && keyFile.pub_key === post.author;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', overflowY: 'auto',
|
||||||
|
padding: '18px 22px', background: '#000',
|
||||||
|
}}>
|
||||||
|
{/* Author line */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 18,
|
||||||
|
background: '#1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#d0d0d0', fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{post.author.slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab({ kind: 'author', pub: post.author })}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', padding: 0,
|
||||||
|
color: '#fff', fontWeight: 700, fontSize: 14, cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shortAddr(post.author, 8)}
|
||||||
|
</button>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11 }}>
|
||||||
|
{new Date(post.created_at * 1000).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mine && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', borderRadius: 999,
|
||||||
|
border: '1px solid #3a2020', background: 'transparent',
|
||||||
|
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
|
||||||
|
cursor: busy ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>Delete</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 15, lineHeight: 1.55,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}>
|
||||||
|
{renderBody(post.content, setTab)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.has_attachment && (
|
||||||
|
<img
|
||||||
|
src={attachmentURL(post.post_id)}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%', maxHeight: 520, borderRadius: 14,
|
||||||
|
display: 'block', marginBottom: 14,
|
||||||
|
}}
|
||||||
|
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Engagement bar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 16, alignItems: 'center',
|
||||||
|
padding: '10px 0', borderTop: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={toggleLike}
|
||||||
|
disabled={busy || !keyFile}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: stats?.liked_by_me ? '#f4212e' : '#8b8b8b',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: keyFile ? 'pointer' : 'default',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats?.liked_by_me ? '❤' : '♡'} {stats?.likes ?? post.likes}
|
||||||
|
</button>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 13 }}>
|
||||||
|
👁 {stats?.views ?? post.views}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: 10, borderRadius: 10,
|
||||||
|
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||||
|
}}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render post body with #hashtags turned into clickable buttons that
|
||||||
|
* jump the feed tab. Basic — no markdown, no emoji polish yet.
|
||||||
|
*/
|
||||||
|
function renderBody(
|
||||||
|
text: string,
|
||||||
|
setTab: (t: { kind: 'hashtag'; tag: string }) => void,
|
||||||
|
): React.ReactNode[] {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
const re = /(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
if (m.index > last) parts.push(text.slice(last, m.index));
|
||||||
|
const tag = m[1].slice(1);
|
||||||
|
parts.push(
|
||||||
|
<button
|
||||||
|
key={`tag-${m.index}`}
|
||||||
|
onClick={() => setTab({ kind: 'hashtag', tag })}
|
||||||
|
style={{
|
||||||
|
color: '#1d9bf0', background: 'transparent', border: 'none',
|
||||||
|
padding: 0, font: 'inherit', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m[1]}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
last = m.index + m[1].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push(text.slice(last));
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
93
desktop/src/sections/feed/PostList.tsx
Normal file
93
desktop/src/sections/feed/PostList.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// PostList — rows within the Feed middle column. Clicking a row sets
|
||||||
|
// the selected post in the store; the detail pane reacts.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import type { FeedPostItem } from '@/lib/feed';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
posts: FeedPostItem[];
|
||||||
|
activeID: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostList({ posts, activeID }: Props): React.ReactElement {
|
||||||
|
const select = useStore(s => s.setFeedSelectedPost);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(p => (
|
||||||
|
<PostRow
|
||||||
|
key={p.post_id}
|
||||||
|
post={p}
|
||||||
|
active={p.post_id === activeID}
|
||||||
|
onClick={() => select(p.post_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostRow({ post, active, onClick }: {
|
||||||
|
post: FeedPostItem; active: boolean; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const author = shortAddr(post.author, 6);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '12px 14px', borderBottom: '1px solid #1f1f1f',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
color: '#8b8b8b', fontSize: 11, marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: 'monospace', color: '#d0d0d0' }}>{author}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatRelative(post.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 13, lineHeight: 1.45,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
// Visual truncate; the detail pane shows the full thing.
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 4,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
} as React.CSSProperties}>
|
||||||
|
{post.content}
|
||||||
|
</div>
|
||||||
|
{post.has_attachment && (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>
|
||||||
|
🖼 attachment
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
color: '#6a6a6a', fontSize: 11, marginTop: 6,
|
||||||
|
display: 'flex', gap: 12,
|
||||||
|
}}>
|
||||||
|
<span>❤ {post.likes}</span>
|
||||||
|
<span>👁 {post.views}</span>
|
||||||
|
{post.hashtags && post.hashtags.length > 0 && (
|
||||||
|
<span style={{ color: '#1d9bf0' }}>
|
||||||
|
{post.hashtags.slice(0, 3).map(t => `#${t}`).join(' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(unixSec: number): string {
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||||
|
if (diff < 60) return `${diff}s`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import React from 'react';
|
// Feed section — re-exports into the Shell's PANES map. Real
|
||||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
// implementation lives in FeedTabs (left) + FeedPane (right); they
|
||||||
|
// share state via zustand's store.feedTab / store.feedSelectedPost.
|
||||||
|
|
||||||
export function FeedList(): React.ReactElement {
|
export { FeedTabs as FeedList } from './FeedTabs';
|
||||||
return <SectionPlaceholder title="Feed" note="For You · Following · Trending · Hashtag" />;
|
export { FeedPane as FeedDetail } from './FeedPane';
|
||||||
}
|
|
||||||
export function FeedDetail(): React.ReactElement {
|
|
||||||
return <SectionPlaceholder title="Feed" note="Select a feed tab to browse posts." centered />;
|
|
||||||
}
|
|
||||||
|
|||||||
52
desktop/src/sections/wallet/ReceiveModal.tsx
Normal file
52
desktop/src/sections/wallet/ReceiveModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// ReceiveModal — shows this wallet's pub key + a copy button. QR-code
|
||||||
|
// polish goes in rc1 (needs a deps pull for qrcode-svg or similar).
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { Backdrop, Header, primaryBtnStyle } from './SendModal';
|
||||||
|
|
||||||
|
export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
if (!keyFile) return <></>;
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try { await navigator.clipboard.writeText(keyFile.pub_key); setCopied(true); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Backdrop onClose={onClose}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<Header title="Receive" onClose={onClose} busy={false} />
|
||||||
|
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 1.5 }}>
|
||||||
|
Share your public key — anyone can send you tokens or add you as
|
||||||
|
a contact using this address.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="selectable" style={{
|
||||||
|
marginTop: 14, padding: 14, borderRadius: 10,
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
||||||
|
wordBreak: 'break-all', lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{keyFile.pub_key}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
style={primaryBtnStyle(false)}
|
||||||
|
>{copied ? 'Copied!' : 'Copy'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Backdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
desktop/src/sections/wallet/SendModal.tsx
Normal file
219
desktop/src/sections/wallet/SendModal.tsx
Normal 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 };
|
||||||
147
desktop/src/sections/wallet/WalletDetailPane.tsx
Normal file
147
desktop/src/sections/wallet/WalletDetailPane.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// WalletDetailPane — right pane of the Wallet section. Either the
|
||||||
|
// selected tx's detail or a placeholder when nothing is selected.
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getTxDetail, type TxDetail } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
function formatT(ut: number | string): string {
|
||||||
|
const n = typeof ut === 'string' ? parseInt(ut, 10) : ut;
|
||||||
|
if (!Number.isFinite(n)) return '—';
|
||||||
|
return (n / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletDetailPane(): React.ReactElement {
|
||||||
|
const sel = useStore(s => s.walletSel);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const [tx, setTx] = useState<TxDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sel.kind !== 'tx') { setTx(null); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
getTxDetail(sel.id)
|
||||||
|
.then(t => { if (!cancelled) setTx(t); })
|
||||||
|
.catch(() => { if (!cancelled) setTx(null); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
if (sel.kind !== 'tx') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Pick a transaction from the list on the left to see its details.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Placeholder note="Loading…" />;
|
||||||
|
if (!tx) return <Placeholder note="Transaction not found on this node." />;
|
||||||
|
|
||||||
|
const outgoing = !!keyFile && tx.from === keyFile.pub_key;
|
||||||
|
const amountUT = tx.amount_ut;
|
||||||
|
const amountColor = amountUT === 0 ? '#8b8b8b'
|
||||||
|
: outgoing ? '#f0b35a' : '#3ba55d';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', overflowY: 'auto',
|
||||||
|
padding: '20px 24px', background: '#000',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase' }}>
|
||||||
|
{tx.type.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: amountColor, fontSize: 30, fontWeight: 800, marginTop: 4,
|
||||||
|
}}>
|
||||||
|
{amountUT === 0 ? '—' : `${outgoing ? '−' : '+'}${formatT(amountUT)} T`}
|
||||||
|
</div>
|
||||||
|
{tx.memo && (
|
||||||
|
<div style={{ color: '#e0e0e0', fontSize: 13, marginTop: 6, fontStyle: 'italic' }}>
|
||||||
|
“{tx.memo}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 22, display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(120px, auto) 1fr', rowGap: 10, columnGap: 20,
|
||||||
|
}}>
|
||||||
|
<Cell label="ID">{tx.id}</Cell>
|
||||||
|
<Cell label="From">{tx.from_addr ?? shortAddr(tx.from, 8)}</Cell>
|
||||||
|
{tx.to && <Cell label="To">{tx.to_addr ?? shortAddr(tx.to, 8)}</Cell>}
|
||||||
|
<Cell label="Amount">{formatT(tx.amount_ut)} T</Cell>
|
||||||
|
<Cell label="Fee">{formatT(tx.fee_ut)} T</Cell>
|
||||||
|
<Cell label="Time">{new Date(tx.time).toLocaleString()}</Cell>
|
||||||
|
<Cell label="Block">#{tx.block_index} · {shortAddr(tx.block_hash, 8)}</Cell>
|
||||||
|
{typeof tx.gas_used === 'number' && tx.gas_used > 0 && (
|
||||||
|
<Cell label="Gas used">{tx.gas_used.toLocaleString()}</Cell>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(tx.payload) && (
|
||||||
|
<details style={{
|
||||||
|
marginTop: 22, background: '#0a0a0a',
|
||||||
|
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
|
||||||
|
}}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
Payload
|
||||||
|
</summary>
|
||||||
|
<pre className="selectable" style={{
|
||||||
|
marginTop: 8, color: '#d0d0d0', fontSize: 11, lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(tx.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tx.payload_hex && (
|
||||||
|
<details style={{
|
||||||
|
marginTop: 10, background: '#0a0a0a',
|
||||||
|
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
|
||||||
|
}}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
Payload (hex)
|
||||||
|
</summary>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
marginTop: 8, color: '#d0d0d0', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{tx.payload_hex}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1, textTransform: 'uppercase',
|
||||||
|
}}>{label}</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}>{children}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Placeholder({ note }: { note: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#6a6a6a', fontSize: 13, padding: 40,
|
||||||
|
}}>{note}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
desktop/src/sections/wallet/WalletOverview.tsx
Normal file
222
desktop/src/sections/wallet/WalletOverview.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// WalletOverview — Wallet section left pane.
|
||||||
|
//
|
||||||
|
// Top card: address + balance + primary actions (Send, Receive).
|
||||||
|
// Bottom list: tx history pulled from /api/address/{pub}?limit=100,
|
||||||
|
// clicking a row sets store.walletSel = { kind: 'tx', id } so the
|
||||||
|
// detail pane renders it.
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getBalance, getTxHistory, type TxRow } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { SendModal } from './SendModal';
|
||||||
|
import { ReceiveModal } from './ReceiveModal';
|
||||||
|
|
||||||
|
function formatT(ut: number): string {
|
||||||
|
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletOverview(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const sel = useStore(s => s.walletSel);
|
||||||
|
const setSel = useStore(s => s.setWalletSel);
|
||||||
|
|
||||||
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
|
const [txs, setTxs] = useState<TxRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sendOpen, setSendOpen] = useState(false);
|
||||||
|
const [receiveOpen, setReceiveOpen] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [bal, rows] = await Promise.all([
|
||||||
|
getBalance(keyFile.pub_key),
|
||||||
|
getTxHistory(keyFile.pub_key, 100),
|
||||||
|
]);
|
||||||
|
setBalance(bal);
|
||||||
|
setTxs(rows);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
if (!keyFile) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 14 }}>
|
||||||
|
{/* Account card */}
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 14, padding: 16,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase',
|
||||||
|
}}>Balance</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 26, fontWeight: 800, marginTop: 4 }}>
|
||||||
|
{balance === null ? '—' : `${formatT(balance)} T`}
|
||||||
|
</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
marginTop: 8, wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{keyFile.pub_key}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
|
<PrimaryBtn label="Send" onClick={() => setSendOpen(true)} />
|
||||||
|
<SecondaryBtn label="Receive" onClick={() => setReceiveOpen(true)} />
|
||||||
|
<SecondaryBtn label="Refresh" onClick={load} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TX list */}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase',
|
||||||
|
padding: '0 4px 6px',
|
||||||
|
}}>History</div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 20, textAlign: 'center' }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : txs.length === 0 ? (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 20, textAlign: 'center' }}>
|
||||||
|
No transactions yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 12, overflow: 'hidden',
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
{txs.map((t, i) => (
|
||||||
|
<TxRowView
|
||||||
|
key={t.id}
|
||||||
|
tx={t}
|
||||||
|
me={keyFile.pub_key}
|
||||||
|
active={sel.kind === 'tx' && sel.id === t.id}
|
||||||
|
first={i === 0}
|
||||||
|
onClick={() => setSel({ kind: 'tx', id: t.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sendOpen && <SendModal onClose={() => setSendOpen(false)} onSent={load} />}
|
||||||
|
{receiveOpen && <ReceiveModal onClose={() => setReceiveOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TxRowView({
|
||||||
|
tx, me, active, first, onClick,
|
||||||
|
}: {
|
||||||
|
tx: TxRow; me: string; active: boolean; first: boolean; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const outgoing = tx.from === me;
|
||||||
|
const amountColor = tx.amount_ut === 0 ? '#8b8b8b'
|
||||||
|
: outgoing ? '#f0b35a' : '#3ba55d';
|
||||||
|
const sign = tx.amount_ut === 0 ? '' : outgoing ? '−' : '+';
|
||||||
|
|
||||||
|
const counterparty = outgoing ? (tx.to_addr || tx.to || '—')
|
||||||
|
: (tx.from_addr || tx.from);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderTop: first ? undefined : '1px solid #1f1f1f',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#111'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
color: '#fff', fontSize: 13, fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{prettyType(tx.type)}
|
||||||
|
{tx.memo && (
|
||||||
|
<span style={{ color: '#6a6a6a', fontSize: 11, fontWeight: 400 }}>
|
||||||
|
· {tx.memo.slice(0, 30)}{tx.memo.length > 30 ? '…' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11,
|
||||||
|
fontFamily: 'monospace', marginTop: 2,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{outgoing ? 'to ' : 'from '}
|
||||||
|
{counterparty.startsWith('DC') ? counterparty : shortAddr(counterparty, 6)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<div style={{ color: amountColor, fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{tx.amount_ut === 0 ? '' : `${sign}${formatT(tx.amount_ut)} T`}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 10 }}>
|
||||||
|
{tx.time ? new Date(tx.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyType(t: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
TRANSFER: 'Transfer',
|
||||||
|
RELAY_PROOF: 'Relay fee',
|
||||||
|
REGISTER_RELAY: 'Register relay',
|
||||||
|
HEARTBEAT: 'Heartbeat',
|
||||||
|
CONTACT_REQUEST: 'Contact request',
|
||||||
|
ACCEPT_CONTACT: 'Contact accepted',
|
||||||
|
BLOCK_CONTACT: 'Contact blocked',
|
||||||
|
REGISTER_KEY: 'Identity registered',
|
||||||
|
LINK_DEVICE: 'Device linked',
|
||||||
|
UNLINK_DEVICE: 'Device unlinked',
|
||||||
|
CREATE_POST: 'Post published',
|
||||||
|
DELETE_POST: 'Post deleted',
|
||||||
|
FOLLOW: 'Follow',
|
||||||
|
UNFOLLOW: 'Unfollow',
|
||||||
|
LIKE_POST: 'Like',
|
||||||
|
UNLIKE_POST: 'Unlike',
|
||||||
|
BLOCK_REWARD: 'Block reward',
|
||||||
|
};
|
||||||
|
return map[t] ?? t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 999, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SecondaryBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #1f1f1f',
|
||||||
|
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,42 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
// Wallet section — full implementation.
|
||||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
//
|
||||||
import { useStore } from '@/lib/store';
|
// List pane: account card (address + balance + Send/Receive buttons)
|
||||||
import { getBalance } from '@/lib/api';
|
// + transaction history, grouped by day.
|
||||||
|
// Detail pane: picked tx — full block/fee/payload details, or a
|
||||||
|
// prompt to pick one on empty selection.
|
||||||
|
|
||||||
function formatT(ut: number): string {
|
export { WalletOverview as WalletList } from './WalletOverview';
|
||||||
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
export { WalletDetailPane as WalletDetail } from './WalletDetailPane';
|
||||||
}
|
|
||||||
|
|
||||||
export function WalletList(): React.ReactElement {
|
|
||||||
const keyFile = useStore(s => s.keyFile);
|
|
||||||
const [balance, setBalance] = useState<number | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!keyFile) return;
|
|
||||||
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
|
||||||
}, [keyFile]);
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 14 }}>
|
|
||||||
<div style={{
|
|
||||||
borderRadius: 14, padding: 14,
|
|
||||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
Balance
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800, marginTop: 4 }}>
|
|
||||||
{balance === null ? '—' : `${formatT(balance)} T`}
|
|
||||||
</div>
|
|
||||||
<div className="selectable" style={{
|
|
||||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
|
||||||
marginTop: 6, wordBreak: 'break-all',
|
|
||||||
}}>
|
|
||||||
{keyFile?.pub_key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WalletDetail(): React.ReactElement {
|
|
||||||
return <SectionPlaceholder title="Wallet" note="Transaction history — coming soon." centered />;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user