Compare commits
1 Commits
v2.2.0-alp
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98ac700e0a |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dchain-desktop",
|
||||
"version": "2.2.0-alpha5",
|
||||
"version": "2.2.0-alpha6",
|
||||
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
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';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
// Feed section — re-exports into the Shell's PANES map. Real
|
||||
// implementation lives in FeedTabs (left) + FeedPane (right); they
|
||||
// share state via zustand's store.feedTab / store.feedSelectedPost.
|
||||
|
||||
export function FeedList(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Feed" note="For You · Following · Trending · Hashtag" />;
|
||||
}
|
||||
export function FeedDetail(): React.ReactElement {
|
||||
return <SectionPlaceholder title="Feed" note="Select a feed tab to browse posts." centered />;
|
||||
}
|
||||
export { FeedTabs as FeedList } from './FeedTabs';
|
||||
export { FeedPane as FeedDetail } from './FeedPane';
|
||||
|
||||
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';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getBalance } from '@/lib/api';
|
||||
// Wallet section — full implementation.
|
||||
//
|
||||
// List pane: account card (address + balance + Send/Receive buttons)
|
||||
// + 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 {
|
||||
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
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 />;
|
||||
}
|
||||
export { WalletOverview as WalletList } from './WalletOverview';
|
||||
export { WalletDetailPane as WalletDetail } from './WalletDetailPane';
|
||||
|
||||
Reference in New Issue
Block a user