feat(client): Twitter-style social feed UI (Phase C of v2.0.0)

Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).

Feed screens

  app/(app)/feed.tsx — Feed tab
    - Three-way tab strip: Подписки / Для вас / В тренде backed by
      /feed/timeline, /feed/foryou, /feed/trending respectively
    - Default landing tab is "Для вас" — surfaces discovery without
      requiring the user to follow anyone first
    - FlatList with pull-to-refresh + viewability-driven view counter
      bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
    - Floating blue compose button → /compose
    - Per-post liked_by_me fetched in batches of 6 after list load

  app/(app)/compose.tsx — post composer modal
    - Fullscreen, Twitter-like header (✕ left, Опубликовать right)
    - Auto-focused multiline TextInput, 4000 char cap
    - Hashtag preview chips that auto-update as you type
    - expo-image-picker + expo-image-manipulator pipeline: resize to
      1080px max-dim, JPEG Q=50 (client-side first-pass compression
      before the mandatory server-side scrub)
    - Live fee estimate + balance guard with a confirmation modal
      ("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
    - Exif: false passed to ImagePicker as an extra privacy layer

  app/(app)/feed/[id].tsx — post detail
    - Full PostCard rendering + detailed info panel (views, likes,
      size, fee, hosting relay, hashtags as tappable chips)
    - Triggers bumpView on mount
    - 410 (on-chain soft-delete) routes back to the feed

  app/(app)/feed/tag/[tag].tsx — hashtag feed

  app/(app)/profile/[address].tsx — rebuilt
    - Twitter-ish profile: avatar, name, address short-form, post count
    - Posts | Инфо tab strip
    - Follow / Unfollow button for non-self profiles (optimistic UI)
    - Edit button on self profile → settings
    - Secondary actions (chat, copy address) when viewing a known contact

Supporting library

  lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
    - publishPost (POST /feed/publish, signed)
    - publishAndCommit (publish → on-chain CREATE_POST)
    - fetchPost / fetchStats / bumpView
    - fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
      fetchHashtag
    - buildCreatePostTx / buildDeletePostTx
    - buildFollowTx / buildUnfollowTx
    - buildLikePostTx / buildUnlikePostTx
    - likePost / unlikePost / followUser / unfollowUser / deletePost
      (high-level helpers that bundle build + submitTx)
    - formatFee, formatRelativeTime, formatCount — Twitter-like display
      helpers

  components/feed/PostCard.tsx — core card component
    - Memoised for performance (N-row re-render on every like elsewhere
      would cost a lot otherwise)
    - Optimistic like toggle with heart-bounce spring animation
    - Hashtag highlighting in body text (tappable → hashtag feed)
    - Long-press context menu (Delete, owner-only)
    - Views / likes / share-link / reply icons in footer row

Navigation cleanup

  - NavBar: removed the SOON pill on the Feed tab (it's shipped now)
  - (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
  - AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
    sub-routes so back-swipe-right closes them

Channel removal (client side)

  - lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
    'channel' flag removed. `kind` field kept for backward compat with
    existing AsyncStorage records.
  - lib/devSeed.ts: dropped the 5 channel seed contacts.
  - components/ChatTile.tsx: removed channel kindIcon branch.

Dependencies

  - expo-image-manipulator added for client-side image compression.
  - expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
    that API to the legacy sub-path; the new streaming API isn't yet
    stable).

Type check

  - npx tsc --noEmit — clean, 0 errors.

Next (not in this commit)

  - Direct attachment-bytes endpoint on the server so post-detail can
    actually render the image (currently shows placeholder with URL)
  - Cross-relay body fetch via /api/relays + hosting_relay pubkey
  - Mentions (@username) with notifications
  - Full-text search

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 19:43:55 +03:00
parent 9e86c93fda
commit 5b64ef2560
68 changed files with 23487 additions and 1 deletions

778
client-app/lib/api.ts Normal file
View File

@@ -0,0 +1,778 @@
/**
* DChain REST API client.
* All requests go to the configured node URL (e.g. http://192.168.1.10:8081).
*/
import type { Envelope, TxRecord, NetStats, Contact } from './types';
import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './crypto';
// ─── Base ─────────────────────────────────────────────────────────────────────
let _nodeUrl = 'http://localhost:8081';
/**
* Listeners invoked AFTER _nodeUrl changes. The WS client registers here so
* that switching nodes in Settings tears down the old socket and re-dials
* the new one (without this, a user who pointed their app at node A would
* keep receiving A's events forever after flipping to B).
*/
const nodeUrlListeners = new Set<(url: string) => void>();
export function setNodeUrl(url: string) {
const normalised = url.replace(/\/$/, '');
if (_nodeUrl === normalised) return;
_nodeUrl = normalised;
for (const fn of nodeUrlListeners) {
try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ }
}
}
export function getNodeUrl(): string {
return _nodeUrl;
}
/** Register a callback for node-URL changes. Returns an unsubscribe fn. */
export function onNodeUrlChange(fn: (url: string) => void): () => void {
nodeUrlListeners.add(fn);
return () => { nodeUrlListeners.delete(fn); };
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`);
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json() as Promise<T>;
}
/**
* Enhanced error reporter for POST failures. The node's `jsonErr` writes
* `{"error": "..."}` as the response body; we parse that out so the UI layer
* can show a meaningful message instead of a raw status code.
*
* Rate-limit and timestamp-skew rejections produce specific strings the UI
* can translate to user-friendly Russian via matcher functions below.
*/
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${_nodeUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
// Try to extract {"error":"..."} payload for a cleaner message.
let detail = text;
try {
const parsed = JSON.parse(text);
if (parsed?.error) detail = parsed.error;
} catch { /* keep raw text */ }
// Include HTTP status so `humanizeTxError` can branch on 429/400/etc.
throw new Error(`${res.status}: ${detail}`);
}
return res.json() as Promise<T>;
}
/**
* Turn a submission error from `post()` / `submitTx()` into a user-facing
* Russian message with actionable hints. Preserves the raw detail at the end
* so advanced users can still copy the original for support.
*/
export function humanizeTxError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw.startsWith('429')) {
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
}
if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
}
if (raw.startsWith('400') && raw.includes('signature')) {
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
}
if (raw.startsWith('400')) {
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
}
if (raw.startsWith('5')) {
return `Ошибка ноды (${raw}). Попробуйте позже.`;
}
// Network-level
if (raw.toLowerCase().includes('network request failed')) {
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
}
return raw;
}
// ─── Chain API ────────────────────────────────────────────────────────────────
export async function getNetStats(): Promise<NetStats> {
return get<NetStats>('/api/netstats');
}
interface AddrResponse {
balance_ut: number;
balance: string;
transactions: Array<{
id: string;
type: string;
from: string;
to?: string;
amount_ut: number;
fee_ut: number;
time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z"
block_index: number;
}>;
tx_count: number;
has_more: boolean;
}
export async function getBalance(pubkey: string): Promise<number> {
const data = await get<AddrResponse>(`/api/address/${pubkey}`);
return data.balance_ut ?? 0;
}
/**
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON.
* Key facts:
* - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON)
* - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON)
* - `timestamp` is RFC3339 string (Go time.Time → string in JSON)
* - There is NO nonce field; dedup is by `id`
*/
export interface RawTx {
id: string; // "tx-<nanoseconds>" or sha256-based
type: string; // "TRANSFER", "CONTACT_REQUEST", etc.
from: string; // hex Ed25519 pub key
to: string; // hex Ed25519 pub key (empty string if N/A)
amount: number; // µT (uint64)
fee: number; // µT (uint64)
memo?: string; // optional
payload: string; // base64(json.Marshal(TypeSpecificPayload))
signature: string; // base64(ed25519.Sign(canonical_bytes, priv))
timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z"
}
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
console.log('[submitTx] →', {
id: tx.id,
type: tx.type,
from: tx.from.slice(0, 12) + '…',
to: tx.to ? tx.to.slice(0, 12) + '…' : '',
amount: tx.amount,
fee: tx.fee,
timestamp: tx.timestamp,
transport: 'auto',
});
// Try the WebSocket path first: no HTTP round-trip, and we get a proper
// submit_ack correlated back to our tx id. Falls through to HTTP if WS is
// unavailable (old node, disconnected, timeout, etc.) so legacy setups
// keep working.
try {
// Lazy import avoids a circular dep with lib/ws.ts (which itself
// imports getNodeUrl from this module).
const { getWSClient } = await import('./ws');
const ws = getWSClient();
if (ws.isConnected()) {
try {
const res = await ws.submitTx(tx);
console.log('[submitTx] ← accepted via WS', res);
return { id: res.id || tx.id, status: 'accepted' };
} catch (e) {
console.warn('[submitTx] WS path failed, falling back to HTTP:', e);
}
}
} catch { /* circular import edge case — ignore and use HTTP */ }
try {
const res = await post<{ id: string; status: string }>('/api/tx', tx);
console.log('[submitTx] ← accepted via HTTP', res);
return res;
} catch (e) {
console.warn('[submitTx] ← rejected', e);
throw e;
}
}
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
return (data.transactions ?? []).map(tx => ({
hash: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount_ut,
fee: tx.fee_ut,
// Convert ISO-8601 string → unix seconds
timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0,
status: 'confirmed' as const,
}));
}
// ─── Relay API ────────────────────────────────────────────────────────────────
//
// Endpoints are mounted at the ROOT of the node HTTP server (not under /api):
// POST /relay/broadcast — publish pre-sealed envelope (proper E2E)
// GET /relay/inbox — fetch envelopes addressed to <pub>
//
// Why /relay/broadcast, not /relay/send?
// /relay/send takes plaintext (msg_b64) и SEAL'ит его ключом релей-ноды —
// это ломает end-to-end шифрование (получатель не сможет расшифровать
// своим ключом). Для E2E всегда используем /relay/broadcast с уже
// запечатанным на клиенте envelope'ом.
/**
* Shape of envelope item returned by GET /relay/inbox (server item type).
* Go `[]byte` поля сериализуются как base64 в JSON — поэтому `nonce` и
* `ciphertext` приходят base64, а не hex. Мы декодируем их в hex для
* совместимости с crypto.ts (decryptMessage принимает hex).
*/
interface InboxItemWire {
id: string;
sender_pub: string;
recipient_pub: string;
fee_ut?: number;
sent_at: number;
sent_at_human?: string;
nonce: string; // base64
ciphertext: string; // base64
}
interface InboxResponseWire {
pub: string;
count: number;
has_more: boolean;
items: InboxItemWire[];
}
/**
* Клиент собирает envelope через encryptMessage и шлёт на /relay/broadcast.
* Серверный формат: `{envelope: <relay.Envelope JSON>}`. Nonce/ciphertext
* там — base64 (Go []byte), а у нас в crypto.ts — hex, так что на wire
* конвертим hex→bytes→base64.
*/
export async function sendEnvelope(params: {
senderPub: string; // X25519 hex
recipientPub: string; // X25519 hex
nonce: string; // hex
ciphertext: string; // hex
senderEd25519Pub?: string; // optional — для будущих fee-релеев
}): Promise<{ id: string; status: string }> {
const sentAt = Math.floor(Date.now() / 1000);
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
// envelope.id — 16 байт, hex. Сервер только проверяет что поле не
// пустое и использует его как ключ mailbox'а. Первые 16 байт nonce
// уже криптографически-случайны (nacl.randomBytes), так что берём их.
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
return post<{ id: string; status: string }>('/relay/broadcast', {
envelope: {
id,
sender_pub: params.senderPub,
recipient_pub: params.recipientPub,
sender_ed25519_pub: params.senderEd25519Pub ?? '',
fee_ut: 0,
fee_sig: null,
nonce: nonceB64,
ciphertext: ctB64,
sent_at: sentAt,
},
});
}
/**
* Fetch envelopes адресованные нам из relay-почтовика.
* Server: `GET /relay/inbox?pub=<x25519hex>` → `{pub, count, has_more, items}`.
* Нормализуем item'ы к clientскому Envelope type: sent_at → timestamp,
* base64 nonce/ciphertext → hex.
*/
export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
const items = Array.isArray(resp?.items) ? resp.items : [];
return items.map((it): Envelope => ({
id: it.id,
sender_pub: it.sender_pub,
recipient_pub: it.recipient_pub,
nonce: bytesToHex(base64ToBytes(it.nonce)),
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
timestamp: it.sent_at ?? 0,
}));
}
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
/**
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
* The response shape is { pub, count, contacts: ContactInfo[] }.
*/
export interface ContactRequestRaw {
requester_pub: string; // Ed25519 pubkey of requester
requester_addr: string; // DChain address (DC…)
status: string; // "pending" | "accepted" | "blocked"
intro: string; // plaintext intro message (may be empty)
fee_ut: number; // anti-spam fee paid in µT
tx_id: string; // transaction ID
created_at: number; // unix seconds
}
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
return data.contacts ?? [];
}
// ─── Identity API ─────────────────────────────────────────────────────────────
export interface IdentityInfo {
pub_key: string;
address: string;
x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string;
registered: boolean;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo | null> {
try {
return await get<IdentityInfo>(`/api/identity/${pubkeyOrAddr}`);
} catch {
return null;
}
}
// ─── Contract API ─────────────────────────────────────────────────────────────
/**
* Response shape from GET /api/contracts/{id}/state/{key}.
* The node handler (node/api_contract.go:handleContractState) returns either:
* { value_b64: null, value_hex: null, ... } when the key is missing
* or
* { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists.
*/
interface ContractStateResponse {
contract_id: string;
key: string;
value_b64: string | null;
value_hex: string | null;
value_u64?: number;
}
/**
* Decode a hex string (lowercase/uppercase) back to the original string value
* it represents. The username registry contract stores values as plain ASCII
* bytes (pubkey hex strings / username strings), so `value_hex` on the wire
* is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret
* those bytes as UTF-8.
*/
function hexToUtf8(hex: string): string {
if (hex.length % 2 !== 0) return '';
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
// TextDecoder is available in Hermes / RN's JS runtime.
try {
return new TextDecoder('utf-8').decode(bytes);
} catch {
// Fallback for environments without TextDecoder.
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return s;
}
}
/** username → address (hex pubkey). Returns null if unregistered. */
export async function resolveUsername(contractId: string, username: string): Promise<string | null> {
try {
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/name:${username}`);
if (!data.value_hex) return null;
const decoded = hexToUtf8(data.value_hex).trim();
return decoded || null;
} catch {
return null;
}
}
/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */
export async function reverseResolve(contractId: string, address: string): Promise<string | null> {
try {
const data = await get<ContractStateResponse>(`/api/contracts/${contractId}/state/addr:${address}`);
if (!data.value_hex) return null;
const decoded = hexToUtf8(data.value_hex).trim();
return decoded || null;
} catch {
return null;
}
}
// ─── Well-known contracts ─────────────────────────────────────────────────────
/**
* Per-entry shape returned by GET /api/well-known-contracts.
* Matches node/api_well_known.go:WellKnownContract.
*/
export interface WellKnownContract {
contract_id: string;
name: string;
version?: string;
deployed_at: number;
}
/**
* Response from GET /api/well-known-contracts.
* `contracts` is keyed by ABI name (e.g. "username_registry").
*/
export interface WellKnownResponse {
count: number;
contracts: Record<string, WellKnownContract>;
}
/**
* Fetch the node's view of canonical system contracts so the client doesn't
* have to force the user to paste contract IDs into settings.
*
* The node returns the earliest-deployed contract per ABI name; this means
* every peer in the same chain reports the same mapping.
*
* Returns `null` on failure (old node, network hiccup, endpoint missing).
*/
export async function fetchWellKnownContracts(): Promise<WellKnownResponse | null> {
try {
return await get<WellKnownResponse>('/api/well-known-contracts');
} catch {
return null;
}
}
// ─── Node version / update-check ─────────────────────────────────────────────
//
// The three calls below let the client:
// 1. fetchNodeVersion() — see what tag/commit/features the connected node
// exposes. Used on first boot + on every chain-switch so we can warn if
// a required feature is missing.
// 2. checkNodeVersion(required) — thin wrapper that returns {supported,
// missing} by diffing a client-expected feature list against the node's.
// 3. fetchUpdateCheck() — ask the node whether its operator has a newer
// release available from their configured release source (Gitea). For
// messenger UX this is purely informational ("the node you're on is N
// versions behind"), never used to update the node automatically.
/** The shape returned by GET /api/well-known-version. */
export interface NodeVersionInfo {
node_version: string;
protocol_version: number;
features: string[];
chain_id?: string;
build?: {
tag: string;
commit: string;
date: string;
dirty: string;
};
}
/** Client-expected protocol version. Bumped only when wire-protocol breaks. */
export const CLIENT_PROTOCOL_VERSION = 1;
/**
* Minimum feature set this client build relies on. A node missing any of
* these is considered "unsupported" — caller should surface an upgrade
* prompt to the user instead of silently failing on the first feature call.
*/
export const CLIENT_REQUIRED_FEATURES = [
'chain_id',
'identity_registry',
'onboarding_api',
'relay_mailbox',
'ws_submit_tx',
];
/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */
export async function fetchNodeVersion(): Promise<NodeVersionInfo | null> {
try {
return await get<NodeVersionInfo>('/api/well-known-version');
} catch {
return null;
}
}
/**
* Check whether the connected node supports this client's required features
* and protocol version. Returns a decision blob the UI can render directly.
*
* { supported: true } → everything fine
* { supported: false, reason: "...", ... } → show update prompt
* { supported: null, reason: "unreachable" } → couldn't reach the endpoint,
* likely old node — assume OK
* but warn quietly.
*/
export async function checkNodeVersion(
required: string[] = CLIENT_REQUIRED_FEATURES,
): Promise<{
supported: boolean | null;
reason?: string;
missing?: string[];
info?: NodeVersionInfo;
}> {
const info = await fetchNodeVersion();
if (!info) {
return { supported: null, reason: 'unreachable' };
}
if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) {
return {
supported: false,
reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`,
info,
};
}
const have = new Set(info.features || []);
const missing = required.filter((f) => !have.has(f));
if (missing.length > 0) {
return {
supported: false,
reason: `node missing features: ${missing.join(', ')}`,
missing,
info,
};
}
return { supported: true, info };
}
/** The shape returned by GET /api/update-check. */
export interface UpdateCheckResponse {
current: { tag: string; commit: string; date: string; dirty: string };
latest?: { tag: string; commit?: string; url?: string; published_at?: string };
update_available: boolean;
checked_at: string;
source?: string;
}
/**
* GET /api/update-check. Returns null when:
* - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503),
* - upstream Gitea call failed (502),
* - request errored out.
* All three are non-fatal for the client; the UI just doesn't render the
* "update available" banner.
*/
export async function fetchUpdateCheck(): Promise<UpdateCheckResponse | null> {
try {
return await get<UpdateCheckResponse>('/api/update-check');
} catch {
return null;
}
}
// ─── Transaction builder helpers ─────────────────────────────────────────────
import { signBase64 } from './crypto';
/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */
const MIN_TX_FEE = 1000;
const _encoder = new TextEncoder();
/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
// toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z"
return d.toISOString().replace('.000Z', 'Z');
}
/** Unique transaction ID (nanoseconds-like using Date.now + random). */
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
/**
* Canonical bytes for signing — must match identity.txSignBytes in Go exactly.
*
* Go struct field order: id, type, from, to, amount, fee, payload, timestamp.
* JS JSON.stringify preserves insertion order, so we rely on that here.
*/
function txCanonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
const s = 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,
});
return _encoder.encode(s);
}
/** Encode a JS string (UTF-8) to base64. */
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
export function buildTransferTx(params: {
from: string;
to: string;
amount: number;
fee: number;
privKey: string;
memo?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = params.memo ? { memo: params.memo } : {};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'TRANSFER', from: params.from, to: params.to,
amount: params.amount, fee: params.fee, payload, timestamp,
});
return {
id, type: 'TRANSFER', from: params.from, to: params.to,
amount: params.amount, fee: params.fee,
memo: params.memo,
payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* CONTACT_REQUEST transaction.
*
* blockchain.Transaction fields:
* Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT)
* Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT)
* Payload = ContactRequestPayload { intro? } as base64 JSON bytes
*/
export function buildContactRequestTx(params: {
from: string; // sender Ed25519 pubkey
to: string; // recipient Ed25519 pubkey
contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT)
intro?: string; // optional plaintext intro message (≤ 280 chars)
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
// Payload matches ContactRequestPayload{Intro: "..."} in Go
const payloadObj = params.intro ? { intro: params.intro } : {};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'CONTACT_REQUEST', from: params.from, to: params.to,
amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* ACCEPT_CONTACT transaction.
* AcceptContactPayload is an empty struct in Go — no fields needed.
*/
export function buildAcceptContactTx(params: {
from: string; // acceptor Ed25519 pubkey (us — the recipient of the request)
to: string; // requester Ed25519 pubkey
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{}
const canonical = txCanonicalBytes({
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
// ─── Contract call ────────────────────────────────────────────────────────────
/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */
const MIN_CALL_FEE = 1000;
/**
* CALL_CONTRACT transaction.
*
* Payload shape (CallContractPayload):
* { contract_id, method, args_json?, gas_limit }
*
* `amount` is the payment attached to the call and made available to the
* contract as `tx.Amount`. Whether it's collected depends on the contract
* — e.g. username_registry.register requires exactly 10_000 µT. Contracts
* that don't need payment should be called with `amount: 0` (default).
*
* The on-chain tx envelope carries `amount` openly, so the explorer shows
* the exact cost of a call rather than hiding it in a contract-internal
* debit — this was the UX motivation for this field.
*
* `fee` is the NETWORK fee paid to the block validator (not the contract).
* `gas` costs are additional and billed at the live gas price.
*/
export function buildCallContractTx(params: {
from: string;
contractId: string;
method: string;
args?: unknown[]; // JSON-serializable arguments
amount?: number; // µT attached to the call (default 0)
gasLimit?: number; // default 1_000_000
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const amount = params.amount ?? 0;
const argsJson = params.args && params.args.length > 0
? JSON.stringify(params.args)
: '';
const payloadObj = {
contract_id: params.contractId,
method: params.method,
args_json: argsJson,
gas_limit: params.gasLimit ?? 1_000_000,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CALL_CONTRACT', from: params.from, to: '',
amount, fee: MIN_CALL_FEE, payload, timestamp,
});
return {
id, type: 'CALL_CONTRACT', from: params.from, to: '',
amount, fee: MIN_CALL_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* Flat registration fee for a username, in µT.
*
* The native username_registry charges a single flat fee (10 000 µT = 0.01 T)
* per register() call regardless of name length, replacing the earlier
* length-based formula. Flat pricing is easier to communicate and the
* 4-char minimum (enforced both in the client UI and the on-chain contract)
* already removes the squatting pressure that tiered pricing mitigated.
*/
export const USERNAME_REGISTRATION_FEE = 10_000;
/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */
export const MIN_USERNAME_LENGTH = 4;
export const MAX_USERNAME_LENGTH = 32;
/** @deprecated Kept for backward compatibility; always returns the flat fee. */
export function usernameRegistrationFee(_name: string): number {
return USERNAME_REGISTRATION_FEE;
}

168
client-app/lib/crypto.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* Cryptographic operations for DChain messenger.
*
* Ed25519 — transaction signing (via TweetNaCl sign)
* X25519 — Diffie-Hellman key exchange for NaCl box
* NaCl box — authenticated encryption for relay messages
*/
import nacl from 'tweetnacl';
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
import { getRandomBytes } from 'expo-crypto';
import type { KeyFile } from './types';
// ─── PRNG ─────────────────────────────────────────────────────────────────────
// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes.
// Wire nacl to expo-crypto which uses the platform's secure RNG natively.
nacl.setPRNG((output: Uint8Array, length: number) => {
const bytes = getRandomBytes(length);
for (let i = 0; i < length; i++) output[i] = bytes[i];
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error('odd hex length');
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Key generation ───────────────────────────────────────────────────────────
/**
* Generate a new identity: Ed25519 signing keys + X25519 encryption keys.
* Returns a KeyFile compatible with the Go node format.
*/
export function generateKeyFile(): KeyFile {
// Ed25519 for signing / blockchain identity
const signKP = nacl.sign.keyPair();
// X25519 for NaCl box encryption
// nacl.box.keyPair() returns Curve25519 keys
const boxKP = nacl.box.keyPair();
return {
pub_key: bytesToHex(signKP.publicKey),
priv_key: bytesToHex(signKP.secretKey),
x25519_pub: bytesToHex(boxKP.publicKey),
x25519_priv: bytesToHex(boxKP.secretKey),
};
}
// ─── NaCl box encryption ──────────────────────────────────────────────────────
/**
* Encrypt a plaintext message using NaCl box.
* Sender uses their X25519 secret key + recipient's X25519 public key.
* Returns { nonce, ciphertext } as hex strings.
*/
export function encryptMessage(
plaintext: string,
senderSecretHex: string,
recipientPubHex: string,
): { nonce: string; ciphertext: string } {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const message = decodeUTF8(plaintext);
const secretKey = hexToBytes(senderSecretHex);
const publicKey = hexToBytes(recipientPubHex);
const box = nacl.box(message, nonce, publicKey, secretKey);
return {
nonce: bytesToHex(nonce),
ciphertext: bytesToHex(box),
};
}
/**
* Decrypt a NaCl box.
* Recipient uses their X25519 secret key + sender's X25519 public key.
*/
export function decryptMessage(
ciphertextHex: string,
nonceHex: string,
senderPubHex: string,
recipientSecHex: string,
): string | null {
try {
const ciphertext = hexToBytes(ciphertextHex);
const nonce = hexToBytes(nonceHex);
const senderPub = hexToBytes(senderPubHex);
const secretKey = hexToBytes(recipientSecHex);
const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey);
if (!plain) return null;
return encodeUTF8(plain);
} catch {
return null;
}
}
// ─── Ed25519 signing ──────────────────────────────────────────────────────────
/**
* Sign arbitrary data with the Ed25519 private key.
* Returns signature as hex.
*/
export function sign(data: Uint8Array, privKeyHex: string): string {
const secretKey = hexToBytes(privKeyHex);
const sig = nacl.sign.detached(data, secretKey);
return bytesToHex(sig);
}
/**
* Sign arbitrary data with the Ed25519 private key.
* Returns signature as base64 — this is the format the Go blockchain node
* expects ([]byte fields are base64 in JSON).
*/
export function signBase64(data: Uint8Array, privKeyHex: string): string {
const secretKey = hexToBytes(privKeyHex);
const sig = nacl.sign.detached(data, secretKey);
return bytesToBase64(sig);
}
/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Decode base64 → bytes. Accepts both standard and URL-safe variants (the
* node's /relay/inbox returns `[]byte` fields marshalled via Go's default
* json.Marshal which uses standard base64).
*/
export function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
/**
* Verify an Ed25519 signature.
*/
export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean {
try {
return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex));
} catch {
return false;
}
}
// ─── Address helpers ──────────────────────────────────────────────────────────
/** Truncate a long hex address for display: 8...8 */
export function shortAddr(hex: string, chars = 8): string {
if (hex.length <= chars * 2 + 3) return hex;
return `${hex.slice(0, chars)}${hex.slice(-chars)}`;
}

67
client-app/lib/dates.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Date / time форматирование для UI мессенджера.
*
* Все функции принимают **unix-seconds** (совместимо с `Message.timestamp`,
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
*/
// ─── Русские месяцы (genitive для "17 июня 2025") ────────────────────────────
const RU_MONTHS_GEN = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/**
* Day-bucket label для сепараторов внутри чата.
* "Сегодня" / "Вчера" / "17 июня 2025"
*
* @param ts unix-seconds
*/
export function dateBucket(ts: number): string {
const d = new Date(ts * 1000);
const now = new Date();
const yday = new Date(); yday.setDate(now.getDate() - 1);
if (sameDay(d, now)) return 'Сегодня';
if (sameDay(d, yday)) return 'Вчера';
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${d.getFullYear()}`;
}
/**
* Короткое relative-time под углом bubble ("29m", "14m", "3h", "2d", "12:40").
*
* @param ts unix-seconds
*/
export function relTime(ts: number): string {
const now = Date.now();
const diff = now - ts * 1000;
if (diff < 60_000) return 'now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
if (diff < 24 * 3_600_000) return `${Math.floor(diff / 3_600_000)}h`;
if (diff < 7 * 24 * 3_600_000) return `${Math.floor(diff / (24 * 3_600_000))}d`;
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Похоже на relTime, но принимает как unix-seconds, так и unix-ms.
* Используется в chat-list tiles (там timestamp бывает в ms от addedAt).
*/
export function formatWhen(ts: number): string {
// Heuristic: > 1e12 → уже в ms, иначе seconds.
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts;
return relTime(sec);
}
/** "HH:MM" — одна и та же локаль, без дня. */
export function formatHM(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

444
client-app/lib/devSeed.ts Normal file
View File

@@ -0,0 +1,444 @@
/**
* Dev seed — заполняет store фейковыми контактами и сообщениями для UI-теста.
*
* Запускается один раз при монтировании layout'а если store пустой
* (useDevSeed). Реальные контакты через WS/HTTP приходят позже —
* `upsertContact` перезаписывает mock'и по address'у.
*
* Цели seed'а:
* 1. Показать все три типа чатов (direct / group / channel) с разным
* поведением sender-meta.
* 2. Наполнить список чатов до скролла (15+ контактов).
* 3. В каждом чате — ≥15 сообщений для скролла в chat view.
* 4. Продемонстрировать "staircase" (run'ы одного отправителя
* внутри 1h-окна) и переключения между отправителями.
*/
import { useEffect } from 'react';
import { useStore } from './store';
import type { Contact, Message } from './types';
// ─── Детерминированные «pubkey» (64 hex символа) ───────────────────
function fakeHex(seed: number): string {
let h = '';
let x = seed;
for (let i = 0; i < 32; i++) {
x = (x * 1103515245 + 12345) & 0xffffffff;
h += (x & 0xff).toString(16).padStart(2, '0');
}
return h;
}
const now = () => Math.floor(Date.now() / 1000);
const MINE = fakeHex(9999);
// ─── Контакты ──────────────────────────────────────────────────────
// 16 штук: 5 DM + 6 групп + 5 каналов. Поле `addedAt` задаёт порядок в
// списке когда нет messages — ordering-fallback.
const mockContacts: Contact[] = [
// ── DM ──────────────────────────────────────────────────────────
{ address: fakeHex(1001), x25519Pub: fakeHex(2001),
username: 'jordan', addedAt: Date.now() - 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1002), x25519Pub: fakeHex(2002),
alias: 'Myles Wagner', addedAt: Date.now() - 2 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1010), x25519Pub: fakeHex(2010),
username: 'sarah_k', addedAt: Date.now() - 3 * 60 * 60 * 1_000, kind: 'direct',
unread: 2 },
{ address: fakeHex(1011), x25519Pub: fakeHex(2011),
alias: 'Mom', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1012), x25519Pub: fakeHex(2012),
username: 'alex_dev', addedAt: Date.now() - 6 * 60 * 60 * 1_000, kind: 'direct' },
// ── Groups ─────────────────────────────────────────────────────
{ address: fakeHex(1003), x25519Pub: fakeHex(2003),
alias: 'Tahoe weekend 🌲', addedAt: Date.now() - 4 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1004), x25519Pub: fakeHex(2004),
alias: 'Knicks tickets', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'group',
unread: 3 },
{ address: fakeHex(1020), x25519Pub: fakeHex(2020),
alias: 'Family', addedAt: Date.now() - 8 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1021), x25519Pub: fakeHex(2021),
alias: 'Work eng', addedAt: Date.now() - 12 * 60 * 60 * 1_000, kind: 'group',
unread: 7 },
{ address: fakeHex(1022), x25519Pub: fakeHex(2022),
alias: 'Book club', addedAt: Date.now() - 24 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1023), x25519Pub: fakeHex(2023),
alias: 'Tuesday D&D 🎲', addedAt: Date.now() - 30 * 60 * 60 * 1_000, kind: 'group' },
// (Channel seeds removed in v2.0.0 — channels replaced by the social feed.)
];
// ─── Генератор сообщений ───────────────────────────────────────────
// Альт-отправители для group-чатов — нужны только как идентификатор `from`.
const P_TYRA = fakeHex(3001);
const P_MYLES = fakeHex(3002);
const P_NATE = fakeHex(3003);
const P_TYLER = fakeHex(3004);
const P_MOM = fakeHex(3005);
const P_DAD = fakeHex(3006);
const P_SIS = fakeHex(3007);
const P_LEAD = fakeHex(3008);
const P_PM = fakeHex(3009);
const P_QA = fakeHex(3010);
const P_DESIGN= fakeHex(3011);
const P_ANNA = fakeHex(3012);
const P_DM_PEER = fakeHex(3013);
type Msg = Omit<Message, 'id'>;
function list(prefix: string, list: Msg[]): Message[] {
return list.map((m, i) => ({ ...m, id: `${prefix}_${i}` }));
}
function mockMessagesFor(contact: Contact): Message[] {
const peer = contact.x25519Pub;
// ── DM: @jordan ────────────────────────────────────────────────
if (contact.username === 'jordan') {
// Важно: id'ы сообщений используются в replyTo.id, поэтому
// указываем их явно где нужно сшить thread.
const msgs: Message[] = list('jordan', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 22, text: 'Hey, have a sec later today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 21, read: true, text: 'yep around 4pm' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'cool, coffee at the corner spot?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 19, read: true, text: 'works' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'just parked 🚗' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'see you in 5' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 3, read: true, text: "that was a great catchup" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: "totally — thanks for the book rec" },
{ from: peer, mine: false, timestamp: now() - 60 * 40, text: 'Hey Jordan - Got tickets to the Knicks game tomorrow, let me know if you want to come!' },
{ from: peer, mine: false, timestamp: now() - 60 * 39, text: "we've got floor seats 🔥" },
{ from: peer, mine: false, timestamp: now() - 60 * 38, text: "starts at 7, pregame at the bar across the street" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: 'Ah sadly I already have plans' },
{ from: MINE, mine: true, timestamp: now() - 60 * 13, read: true, text: 'maybe next time?' },
{ from: peer, mine: false, timestamp: now() - 60 * 5, text: "no worries — enjoy whatever you're up to" },
{ from: peer, mine: false, timestamp: now() - 60 * 2, text: "wish you could make it tho 🏀" },
]);
// Пришьём reply: MINE-сообщение "Ah sadly…" отвечает на "Hey Jordan - Got tickets…"
const target = msgs.find(m => m.text.startsWith('Hey Jordan - Got tickets'));
const mine = msgs.find(m => m.text === 'Ah sadly I already have plans');
if (target && mine) {
mine.replyTo = {
id: target.id,
author: '@jordan',
text: target.text,
};
}
return msgs;
}
// ── DM: Myles Wagner ───────────────────────────────────────────
if (contact.alias === 'Myles Wagner') {
return list('myles', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'saw the draft, left a bunch of comments' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 29, read: true, text: 'thx, going through them now' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 29, text: 'no rush — tomorrow is fine' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'lunch today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: "can't, stuck in reviews" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: 'tomorrow?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: '✅ tomorrow' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: '',
attachment: {
kind: 'voice',
uri: 'voice-demo://myles-1',
duration: 17,
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'the dchain repo finally built for me' },
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'docker weirdness was the issue' },
{ from: MINE, mine: true, timestamp: now() - 60 * 21, read: true, text: "nice, told you the WSL path would do it" },
{ from: peer, mine: false, timestamp: now() - 60 * 20, text: 'So good!' },
]);
}
// ── DM: @sarah_k (с unread=2) ──────────────────────────────────
if (contact.username === 'sarah_k') {
return list('sarah', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: "hey! been a while" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 28, read: true, text: 'yeah, finally surfaced after the launch crunch' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 27, text: 'how did it go?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 27, read: true, text: "pretty well actually 🙏" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'btw drinks on friday?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'that new wine bar' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'around 7 if you can make it' },
]);
}
// ── DM: Mom ────────────────────────────────────────────────────
if (contact.alias === 'Mom') {
return list('mom', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Did you see the photos from the trip?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 47, read: true, text: 'not yet, send them again?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 47, text: 'ok' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: 'wow, grandma looks great' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'she asked about you!' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'call later?' },
]);
}
// ── DM: @alex_dev ──────────────────────────────────────────────
if (contact.username === 'alex_dev') {
return list('alex', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'did you try the new WASM build?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 11, read: true, text: 'yeah, loader error on start' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 11, text: 'path encoding issue again?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 10, read: true, text: 'probably, checking now' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 8, read: true, text: 'yep, was the trailing slash' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 8, text: 'classic 😅' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'PR for that incoming tomorrow' },
]);
}
// ── Group: Tahoe weekend 🌲 ────────────────────────────────────
if (contact.alias === 'Tahoe weekend 🌲') {
const msgs: Message[] = list('tahoe', [
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: "who's in for Tahoe this weekend?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 49, text: "me!" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 48, read: true, text: "count me in" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: "woohoo 🎉" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 47, text: "planning friday night → sunday evening yeah?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 46, text: "yep, maybe leave friday after lunch" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 1: Eagle Falls hike" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 2: Emerald bay kayak" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 3: lazy breakfast then drive back" },
{
from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: '',
attachment: {
kind: 'file',
uri: 'https://example.com/Lake_Tahoe_Itinerary.pdf',
name: 'Lake_Tahoe_Itinerary.pdf',
size: 97_280, // ~95 KB
mime: 'application/pdf',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 24, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "Eagle falls was stunning last year!" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 31, text: "who's excited for Tahoe this weekend?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 30, text: "I've been checking the forecast — sun all weekend 🌞" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 22, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 21, text: "Day 1 we can hit Eagle Falls" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{
from: P_TYRA, mine: false, timestamp: now() - 60 * 3, text: 'pic from my last trip 😍',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1505245208761-ba872912fac0?w=800',
width: 800,
height: 1000,
mime: 'image/jpeg',
},
},
]);
// Thread: mine "Love it — Eagle falls looks insane" — ответ на
// Myles'овский itinerary-PDF. Берём ПЕРВЫЙ match "Day 1 we can hit
// Eagle Falls" и пришиваем его к первому mine-bubble'у.
const target = msgs.find(m => m.text === 'Day 1 we can hit Eagle Falls');
const reply = msgs.find(m => m.text === 'Love it — Eagle falls looks insane' && m.mine);
if (target && reply) {
reply.replyTo = {
id: target.id,
author: 'Myles Wagner',
text: target.text,
};
}
return msgs;
}
// ── Group: Knicks tickets ──────────────────────────────────────
if (contact.alias === 'Knicks tickets') {
return list('knicks', [
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 20, text: "quick group update — got 5 tickets for thursday" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'wow nice' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'where are we seated?' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 19, text: 'section 102, row 12' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 18, read: true, text: 'thats a great spot' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 18, text: "can someone venmo nate 🙏" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 17, read: true, text: 'sending now' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 32, text: "Ok who's in for tomorrow's game?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 31, text: 'Got 2 extra tickets, first-come-first-served' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 27, text: "I'm in!" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 26, text: 'What time does it start?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 20, read: true, text: "Let's meet at the bar around 6?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 15, text: 'Sounds good' },
]);
}
// ── Group: Family ──────────────────────────────────────────────
if (contact.alias === 'Family') {
return list('family', [
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 36, text: 'remember grandma birthday on sunday' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 36, text: 'noted 🎂' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 35, text: 'who is bringing the cake?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 35, text: "I'll get it from the bakery" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 34, read: true, text: 'I can pick up flowers' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 34, text: 'perfect' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 8, text: 'forecast is rain sunday — backup plan?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 8, text: "we'll move indoors, the living room works" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 7, text: 'works!' },
]);
}
// ── Group: Work eng (unread=7) ─────────────────────────────────
if (contact.alias === 'Work eng') {
return list('work', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 16, text: 'standup at 10 moved to 11 today btw' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 16, text: 'thanks!' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 15, text: "the staging deploy broke again 🙃" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 15, text: "ugh, looking" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 14, text: 'fixed — migration was stuck' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 13, read: true, text: 'Worked for me now 👍' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 5, text: 'reminder: demo tomorrow, slides by eod' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Ill handle the technical half' },
{ from: P_DESIGN,mine: false,timestamp: now() - 60 * 60 * 4, text: 'just posted the v2 mocks in figma' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 3, text: 'chatting with sales — 3 new trials this week' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 2, text: 'flaky test on CI — investigating' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 30, text: 'okay seems like CI is green now' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 28, text: 'retry passed' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 20, text: "we're good for release" },
]);
}
// ── Group: Book club ───────────────────────────────────────────
if (contact.alias === 'Book club') {
return list('book', [
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 96, text: 'next month: "Project Hail Mary"?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 95, read: true, text: '👍' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 94, text: 'yes please' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'halfway through — so good' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'love the linguistics angle' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: "rocky is my favourite character in years" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 28, text: 'agreed' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "let's meet sunday 4pm?" },
]);
}
// ── Group: Tuesday D&D 🎲 ──────────────────────────────────────
if (contact.alias === 'Tuesday D&D 🎲') {
return list('dnd', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 72, text: 'Session 14 recap up on the wiki' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 72, text: '🙏' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: 'can we start 30min late next tuesday? commute issue' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 50, text: 'sure' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 49, read: true, text: 'works for me' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 32, text: 'we pick up where we left — in the dragons cave' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 32, text: 'excited 🐉' },
]);
}
// ── Channel: dchain_updates ────────────────────────────────────
if (contact.username === 'dchain_updates') {
return list('dchain_updates', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 96, text: '🔨 v0.0.1-alpha tagged on Gitea' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 72, text: 'PBFT equivocation-detection тесты зелёные' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'New: /api/peers теперь включает peer-version info' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: '📘 Docs overhaul merged: docs/README.md' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 36, text: 'Schema migration scaffold landed (no-op для текущей версии)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: '🚀 v0.0.1 released' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 10, text: 'Includes: auto-update from Gitea, peer-version gossip, schema migrations' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 20, text: 'Check /api/well-known-version for the full feature list' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'Thanks to all testers — feedback drives the roadmap 🙏' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'v0.0.2 roadmap published: https://git.vsecoder.vodka/vsecoder/dchain' },
{ from: peer, mine: false, timestamp: now() - 60 * 30, text: 'quick heads-up: nightly builds switching to new docker-slim base' },
]);
}
// ── Channel: Relay broadcasts ──────────────────────────────────
if (contact.alias === '⚡ Relay broadcasts') {
return list('relay_bc', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Relay fleet snapshot: 12 active, 3 inactive' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Relay #3 came online in US-east' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Validator set updated: 3→4' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'PBFT view-change детектирован и отработан на блоке 184120' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 15, text: 'Mailbox eviction ran — 42 stale envelopes' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'Relay #8 slashed for equivocation — evidence at block 184202' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'Relay #12 came online in EU-west, registering now…' },
]);
}
// ── Channel: Tech news ────────────────────────────────────────
if (contact.alias === '📰 Tech news') {
return list('tech_news', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Rust 1.78 released — new lints for raw pointers' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Go 1.23 ships range-over-func officially' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Expo SDK 54 drops — new-architecture default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'CVE-2026-1337 patched in libsodium (update your keys)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Matrix protocol adds post-quantum handshakes' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Data-center aerial view — new hyperscaler in Iceland',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'IETF draft: "DNS-over-blockchain"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 6, text: 'GitHub tightens 2FA defaults for orgs' },
]);
}
// ── Channel: Design inspo (unread=12) ──────────────────────────
if (contact.alias === '🎨 Design inspo') {
return list('design_inspo', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Weekly pick: Linear UI v3 breakdown' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 140, text: 'Figma file of the week: "Command bar patterns"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Motion study: Stripe checkout shake-error animation' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: "10 great empty-state illustrations (blogpost)" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Tool: Hatch — colour-palette extractor from photos' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: '🔮 Trend watch: glassmorphism is back (again)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Twitter thread: why rounded buttons are the default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'Framer templates — black friday sale' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'New typeface: "Grotesk Pro" — free for personal use' },
]);
}
// ── Channel: NBA scores ────────────────────────────────────────
if (contact.alias === '🏀 NBA scores') {
return list('nba', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Lakers 112 — Warriors 108 (OT)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 130, text: 'Celtics 128 — Heat 115' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Nuggets 119 — Thunder 102' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 70, text: "Knicks 101 — Bulls 98" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Mavericks 130 — Kings 127' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 24, text: 'Bucks 114 — Sixers 110' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Live: Lakers leading 78-72 at half' },
]);
}
return [];
}
// ─── Hook ──────────────────────────────────────────────────────────
export function useDevSeed() {
const contacts = useStore(s => s.contacts);
const setContacts = useStore(s => s.setContacts);
const setMessages = useStore(s => s.setMessages);
useEffect(() => {
if (contacts.length > 0) return;
setContacts(mockContacts);
for (const c of mockContacts) {
const msgs = mockMessagesFor(c);
if (msgs.length > 0) setMessages(c.address, msgs);
}
}, [contacts.length, setContacts, setMessages]);
}

487
client-app/lib/feed.ts Normal file
View File

@@ -0,0 +1,487 @@
/**
* Feed client — HTTP wrappers + tx builders for the v2.0.0 social feed.
*
* Flow for publishing a post:
* 1. Build the post body (text + optional pre-compressed attachment).
* 2. Sign "publish:<post_id>:<sha256(body)>:<ts>" with the author's
* Ed25519 key.
* 3. POST /feed/publish — server verifies sig, scrubs metadata from
* any image attachment, stores the body, returns
* { post_id, content_hash, size, estimated_fee_ut, hashtags }.
* 4. Submit CREATE_POST tx on-chain with THE RETURNED content_hash +
* size + hosting_relay. Fee = server's estimate (server credits
* the full amount to the hosting relay).
*
* For reads we hit /feed/timeline, /feed/foryou, /feed/trending,
* /feed/author/{pub}, /feed/hashtag/{tag}. All return a list of
* FeedPostItem (chain metadata enriched with body + stats).
*/
import { getNodeUrl, submitTx, type RawTx } from './api';
import {
bytesToBase64, bytesToHex, hexToBytes, signBase64, sign as signHex,
} from './crypto';
// ─── Types (mirrors node/api_feed.go shapes) ──────────────────────────────
/** Single post as returned by /feed/author, /feed/timeline, etc. */
export interface FeedPostItem {
post_id: string;
author: string; // hex Ed25519
content: string;
content_type?: string; // "text/plain" | "text/markdown"
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; // hex sha256 over scrubbed bytes
size: number;
hashtags: string[];
estimated_fee_ut: number;
}
export interface TimelineResponse {
count: number;
posts: FeedPostItem[];
}
// ─── HTTP helpers ─────────────────────────────────────────────────────────
async function getJSON<T>(path: string): Promise<T> {
const res = await fetch(`${getNodeUrl()}${path}`);
if (!res.ok) {
throw new Error(`GET ${path}${res.status}`);
}
return res.json() as Promise<T>;
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${getNodeUrl()}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const parsed = JSON.parse(text);
if (parsed?.error) detail = parsed.error;
} catch { /* keep raw */ }
throw new Error(`POST ${path}${res.status}: ${detail}`);
}
return res.json() as Promise<T>;
}
// ─── Publish flow ─────────────────────────────────────────────────────────
/**
* Compute a post_id from author + nanoseconds-ish entropy + content prefix.
* Must match the server-side hash trick (`sha256(author-nanos-content)[:16]`).
* Details don't matter — server only checks the id is non-empty. We just
* need uniqueness across the author's own posts.
*/
async function computePostID(author: string, content: string): Promise<string> {
const { digestStringAsync, CryptoDigestAlgorithm, CryptoEncoding } =
await import('expo-crypto');
const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`;
const hex = await digestStringAsync(
CryptoDigestAlgorithm.SHA256,
seed,
{ encoding: CryptoEncoding.HEX },
);
return hex.slice(0, 32); // 16 bytes = 32 hex chars
}
/**
* sha256 of a UTF-8 string (optionally with appended bytes for attachments).
* Returns hex. Uses expo-crypto for native speed.
*/
async function sha256Hex(content: string, attachment?: Uint8Array): Promise<string> {
const { digest, CryptoDigestAlgorithm } = await import('expo-crypto');
const encoder = new TextEncoder();
const contentBytes = encoder.encode(content);
const combined = new Uint8Array(
contentBytes.length + (attachment ? attachment.length : 0),
);
combined.set(contentBytes, 0);
if (attachment) combined.set(attachment, contentBytes.length);
const buf = await digest(CryptoDigestAlgorithm.SHA256, combined);
// ArrayBuffer → hex
const view = new Uint8Array(buf);
return bytesToHex(view);
}
export interface PublishParams {
author: string; // hex Ed25519 pubkey
privKey: string; // hex Ed25519 privkey
content: string;
attachment?: Uint8Array;
attachmentMIME?: string; // "image/jpeg" | "image/png" | "video/mp4" | ...
replyTo?: string;
quoteOf?: string;
}
/**
* Publish a post: POSTs /feed/publish with a signed body. Returns the
* server's response so the caller can submit a matching CREATE_POST tx.
*
* Client is expected to have compressed the attachment FIRST (see
* `lib/media.ts`) — this function does not re-compress, only signs and
* uploads. Server will scrub metadata regardless.
*/
export async function publishPost(p: PublishParams): Promise<PublishResponse> {
const postID = await computePostID(p.author, p.content);
const clientHash = await sha256Hex(p.content, p.attachment);
const ts = Math.floor(Date.now() / 1000);
// Sign: "publish:<post_id>:<raw_content_hash>:<ts>"
const encoder = new TextEncoder();
const msg = encoder.encode(`publish:${postID}:${clientHash}:${ts}`);
const sig = signBase64(msg, p.privKey);
const body = {
post_id: postID,
author: p.author,
content: p.content,
content_type: 'text/plain',
attachment_b64: p.attachment ? bytesToBase64(p.attachment) : undefined,
attachment_mime: p.attachmentMIME ?? undefined,
reply_to: p.replyTo,
quote_of: p.quoteOf,
sig,
ts,
};
return postJSON<PublishResponse>('/feed/publish', body);
}
/**
* Full publish flow: POST /feed/publish → on-chain CREATE_POST tx.
* Returns the final post_id (same as server response; echoed for UX).
*/
export async function publishAndCommit(p: PublishParams): 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;
}
// ─── Read endpoints ───────────────────────────────────────────────────────
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
try {
return await getJSON<FeedPostItem>(`/feed/post/${postID}`);
} catch (e: any) {
if (/→\s*404\b/.test(String(e?.message))) return null;
if (/→\s*410\b/.test(String(e?.message))) return null;
throw e;
}
}
export async function fetchStats(postID: string, me?: string): Promise<PostStats | null> {
try {
const qs = me ? `?me=${me}` : '';
return await getJSON<PostStats>(`/feed/post/${postID}/stats${qs}`);
} catch {
return null;
}
}
/**
* Increment the off-chain view counter. Fire-and-forget — failures here
* are not fatal to the UX, so we swallow errors.
*/
export async function bumpView(postID: string): Promise<void> {
try {
await postJSON<unknown>(`/feed/post/${postID}/view`, undefined);
} catch { /* ignore */ }
}
export async function fetchAuthorPosts(pub: string, limit = 30): Promise<FeedPostItem[]> {
const resp = await getJSON<TimelineResponse>(`/feed/author/${pub}?limit=${limit}`);
return resp.posts ?? [];
}
export async function fetchTimeline(followerPub: string, limit = 30): Promise<FeedPostItem[]> {
const resp = await getJSON<TimelineResponse>(`/feed/timeline?follower=${followerPub}&limit=${limit}`);
return resp.posts ?? [];
}
export async function fetchForYou(pub: string, limit = 30): Promise<FeedPostItem[]> {
const resp = await getJSON<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
return resp.posts ?? [];
}
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
const resp = await getJSON<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
return resp.posts ?? [];
}
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
const cleanTag = tag.replace(/^#/, '');
const resp = await getJSON<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(cleanTag)}?limit=${limit}`);
return resp.posts ?? [];
}
// ─── Transaction builders ─────────────────────────────────────────────────
//
// These match the blockchain.Event* payloads 1-to-1 and produce already-
// signed RawTx objects ready for submitTx.
/** RFC3339 second-precision timestamp — matches Go time.Time default JSON. */
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)}`;
}
const _encoder = new TextEncoder();
function txCanonicalBytes(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));
}
/**
* CREATE_POST tx. content_hash is the HEX sha256 returned by /feed/publish;
* must be converted to base64 bytes for the on-chain payload (Go []byte →
* base64 in JSON).
*/
export function buildCreatePostTx(params: {
from: string;
privKey: string;
postID: string;
contentHash: string; // hex from server
size: number;
hostingRelay: string;
fee: number;
replyTo?: string;
quoteOf?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
post_id: params.postID,
content_hash: bytesToBase64(hexToBytes(params.contentHash)),
size: params.size,
hosting_relay: params.hostingRelay,
reply_to: params.replyTo ?? '',
quote_of: params.quoteOf ?? '',
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'CREATE_POST', from: params.from, to: '',
amount: 0, fee: params.fee, payload, timestamp,
});
return {
id, type: 'CREATE_POST', from: params.from, to: '',
amount: 0, fee: params.fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildDeletePostTx(params: {
from: string;
privKey: string;
postID: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'DELETE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'DELETE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildFollowTx(params: {
from: string;
privKey: string;
target: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'FOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'FOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildUnfollowTx(params: {
from: string;
privKey: string;
target: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'UNFOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'UNFOLLOW', from: params.from, to: params.target,
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildLikePostTx(params: {
from: string;
privKey: string;
postID: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'LIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'LIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
export function buildUnlikePostTx(params: {
from: string;
privKey: string;
postID: string;
fee?: number;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ post_id: params.postID }));
const fee = params.fee ?? 1000;
const canonical = txCanonicalBytes({
id, type: 'UNLIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
});
return {
id, type: 'UNLIKE_POST', from: params.from, to: '',
amount: 0, fee, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
// ─── High-level helpers (combine build + submit) ─────────────────────────
export async function likePost(params: { from: string; privKey: string; postID: string }) {
await submitTx(buildLikePostTx(params));
}
export async function unlikePost(params: { from: string; privKey: string; postID: string }) {
await submitTx(buildUnlikePostTx(params));
}
export async function followUser(params: { from: string; privKey: string; target: string }) {
await submitTx(buildFollowTx(params));
}
export async function unfollowUser(params: { from: string; privKey: string; target: string }) {
await submitTx(buildUnfollowTx(params));
}
export async function deletePost(params: { from: string; privKey: string; postID: string }) {
await submitTx(buildDeletePostTx(params));
}
// ─── Formatting helpers ───────────────────────────────────────────────────
/** Convert µT to display token amount (0.xxx T). */
export function formatFee(feeUT: number): string {
const t = feeUT / 1_000_000;
if (t < 0.001) return `${feeUT} µT`;
return `${t.toFixed(4)} T`;
}
/** Relative time formatter, Twitter-like ("5m", "3h", "Dec 15"). */
export function formatRelativeTime(unixSeconds: number): string {
const now = Date.now() / 1000;
const delta = now - unixSeconds;
if (delta < 60) return 'just now';
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h`;
if (delta < 7 * 86400) return `${Math.floor(delta / 86400)}d`;
const d = new Date(unixSeconds * 1000);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
/** Compact count formatter ("1.2K", "3.4M"). */
export function formatCount(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
// Prevent unused-import lint when nothing else touches signHex.
export const _feedSignRaw = signHex;

101
client-app/lib/storage.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Persistent storage for keys and app settings.
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
* On web: falls back to localStorage (dev only).
*/
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { KeyFile, Contact, NodeSettings } from './types';
// ─── Keys ─────────────────────────────────────────────────────────────────────
const KEYFILE_KEY = 'dchain_keyfile';
const CONTACTS_KEY = 'dchain_contacts';
const SETTINGS_KEY = 'dchain_settings';
const CHATS_KEY = 'dchain_chats';
/** Save the key file in secure storage (encrypted on device). */
export async function saveKeyFile(kf: KeyFile): Promise<void> {
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
}
/** Load key file. Returns null if not set. */
export async function loadKeyFile(): Promise<KeyFile | null> {
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
if (!raw) return null;
return JSON.parse(raw) as KeyFile;
}
/** Delete key file (logout / factory reset). */
export async function deleteKeyFile(): Promise<void> {
await SecureStore.deleteItemAsync(KEYFILE_KEY);
}
// ─── Node settings ─────────────────────────────────────────────────────────────
const DEFAULT_SETTINGS: NodeSettings = {
nodeUrl: 'http://localhost:8081',
contractId: '',
};
export async function loadSettings(): Promise<NodeSettings> {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return DEFAULT_SETTINGS;
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
}
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
const current = await loadSettings();
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function loadContacts(): Promise<Contact[]> {
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
if (!raw) return [];
return JSON.parse(raw) as Contact[];
}
export async function saveContact(c: Contact): Promise<void> {
const contacts = await loadContacts();
const idx = contacts.findIndex(x => x.address === c.address);
if (idx >= 0) contacts[idx] = c;
else contacts.push(c);
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
}
export async function deleteContact(address: string): Promise<void> {
const contacts = await loadContacts();
await AsyncStorage.setItem(
CONTACTS_KEY,
JSON.stringify(contacts.filter(c => c.address !== address)),
);
}
// ─── Message cache (per-chat local store) ────────────────────────────────────
export interface CachedMessage {
id: string;
from: string;
text: string;
timestamp: number;
mine: boolean;
}
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
if (!raw) return [];
return JSON.parse(raw) as CachedMessage[];
}
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
const msgs = await loadMessages(chatId);
// Deduplicate by id
if (msgs.find(m => m.id === msg.id)) return;
msgs.push(msg);
// Keep last 500 messages per chat
const trimmed = msgs.slice(-500);
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
}

128
client-app/lib/store.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Global app state via Zustand.
* Keeps runtime state; persistent data lives in storage.ts.
*/
import { create } from 'zustand';
import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types';
interface AppState {
// Identity
keyFile: KeyFile | null;
username: string | null;
setKeyFile: (kf: KeyFile | null) => void;
setUsername: (u: string | null) => void;
// Node settings
settings: NodeSettings;
setSettings: (s: Partial<NodeSettings>) => void;
// Contacts
contacts: Contact[];
setContacts: (contacts: Contact[]) => void;
upsertContact: (c: Contact) => void;
// Chats (derived from contacts + messages)
chats: Chat[];
setChats: (chats: Chat[]) => void;
// Active chat messages
messages: Record<string, Message[]>; // key: contactAddress
setMessages: (chatId: string, msgs: Message[]) => void;
appendMessage: (chatId: string, msg: Message) => void;
// Contact requests (pending)
requests: ContactRequest[];
setRequests: (reqs: ContactRequest[]) => void;
// Balance
balance: number;
setBalance: (b: number) => void;
// Loading / error states
loading: boolean;
setLoading: (v: boolean) => void;
error: string | null;
setError: (e: string | null) => void;
// Nonce cache (to avoid refetching)
nonce: number;
setNonce: (n: number) => void;
// Per-contact unread counter (reset on chat open, bumped on peer msg arrive).
// Keyed by contactAddress (Ed25519 pubkey hex).
unreadByContact: Record<string, number>;
incrementUnread: (contactAddress: string) => void;
clearUnread: (contactAddress: string) => void;
/** Bootstrap state: `true` after initial loadKeyFile + loadSettings done. */
booted: boolean;
setBooted: (b: boolean) => void;
}
export const useStore = create<AppState>((set, get) => ({
keyFile: null,
username: null,
setKeyFile: (kf) => set({ keyFile: kf }),
setUsername: (u) => set({ username: u }),
settings: {
nodeUrl: 'http://localhost:8081',
contractId: '',
},
setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })),
contacts: [],
setContacts: (contacts) => set({ contacts }),
upsertContact: (c) => set(state => {
const idx = state.contacts.findIndex(x => x.address === c.address);
if (idx >= 0) {
const updated = [...state.contacts];
updated[idx] = c;
return { contacts: updated };
}
return { contacts: [...state.contacts, c] };
}),
chats: [],
setChats: (chats) => set({ chats }),
messages: {},
setMessages: (chatId, msgs) => set(state => ({
messages: { ...state.messages, [chatId]: msgs },
})),
appendMessage: (chatId, msg) => set(state => {
const current = state.messages[chatId] ?? [];
if (current.find(m => m.id === msg.id)) return {};
return { messages: { ...state.messages, [chatId]: [...current, msg] } };
}),
requests: [],
setRequests: (reqs) => set({ requests: reqs }),
balance: 0,
setBalance: (b) => set({ balance: b }),
loading: false,
setLoading: (v) => set({ loading: v }),
error: null,
setError: (e) => set({ error: e }),
nonce: 0,
setNonce: (n) => set({ nonce: n }),
unreadByContact: {},
incrementUnread: (addr) => set(state => ({
unreadByContact: {
...state.unreadByContact,
[addr]: (state.unreadByContact[addr] ?? 0) + 1,
},
})),
clearUnread: (addr) => set(state => {
if (!state.unreadByContact[addr]) return {};
const next = { ...state.unreadByContact };
delete next[addr];
return { unreadByContact: next };
}),
booted: false,
setBooted: (b) => set({ booted: b }),
}));

149
client-app/lib/types.ts Normal file
View File

@@ -0,0 +1,149 @@
// ─── Key material ────────────────────────────────────────────────────────────
export interface KeyFile {
pub_key: string; // hex Ed25519 public key (32 bytes)
priv_key: string; // hex Ed25519 private key (64 bytes)
x25519_pub: string; // hex X25519 public key (32 bytes)
x25519_priv: string; // hex X25519 private key (32 bytes)
}
// ─── Contact ─────────────────────────────────────────────────────────────────
/**
* Тип беседы в v2.0.0 — только direct (1:1 E2E чат). Каналы убраны в
* пользу публичной ленты (см. lib/feed.ts). Поле `kind` осталось ради
* обратной совместимости со старыми записями в AsyncStorage; новые
* контакты не пишут его.
*/
export type ContactKind = 'direct' | 'group';
export interface Contact {
address: string; // Ed25519 pubkey hex — blockchain address
x25519Pub: string; // X25519 pubkey hex — encryption key
username?: string; // @name from registry contract
alias?: string; // local nickname
addedAt: number; // unix ms
/** Legacy field (kept for backward compat with existing AsyncStorage). */
kind?: ContactKind;
/** Количество непрочитанных — опционально, проставляется WS read-receipt'ами. */
unread?: number;
}
// ─── Messages ─────────────────────────────────────────────────────────────────
export interface Envelope {
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
id: string;
sender_pub: string; // X25519 hex
recipient_pub: string; // X25519 hex
nonce: string; // hex 24 bytes
ciphertext: string; // hex NaCl box
timestamp: number; // unix seconds (server's sent_at, normalised client-side)
}
/**
* Вложение к сообщению. MVP — хранится как URI на локальной файловой
* системе клиента (expo-image-picker / expo-document-picker / expo-av
* возвращают именно такие URI). Wire-формат для передачи attachment'ов
* через relay-envelope ещё не финализирован — пока этот тип для UI'а и
* локального отображения.
*
* Формат по kind:
* image — width/height опциональны (image-picker их отдаёт)
* video — same + duration в секундах
* voice — duration в секундах, нет дизайна превью кроме waveform-stub
* file — name + size в байтах, тип через mime
*/
export type AttachmentKind = 'image' | 'video' | 'voice' | 'file';
export interface Attachment {
kind: AttachmentKind;
uri: string; // локальный file:// URI или https:// (incoming decoded)
mime?: string; // 'image/jpeg', 'application/pdf', …
name?: string; // имя файла (для file)
size?: number; // байты (для file)
width?: number; // image/video
height?: number; // image/video
duration?: number; // seconds (video/voice)
/** Для kind='video' — рендерить как круглое видео-сообщение (Telegram-style). */
circle?: boolean;
}
export interface Message {
id: string;
from: string; // X25519 pubkey of sender
text: string;
timestamp: number;
mine: boolean;
/** true если сообщение было отредактировано. Показываем "Edited" в углу. */
edited?: boolean;
/**
* Для mine=true — true если получатель его прочитал.
* UI: пустая галочка = отправлено, filled = прочитано.
* Для mine=false не используется.
*/
read?: boolean;
/** Одно вложение. Multi-attach пока не поддерживается — будет массивом. */
attachment?: Attachment;
/**
* Если сообщение — ответ на другое, здесь лежит ссылка + short preview
* того оригинала. id используется для scroll-to + highlight; text/author
* — для рендера "quoted"-блока внутри текущего bubble'а без запроса
* исходного сообщения (копия замороженная в момент ответа).
*/
replyTo?: {
id: string;
text: string;
author: string; // @username / alias / "you"
};
}
// ─── Chat ────────────────────────────────────────────────────────────────────
export interface Chat {
contactAddress: string; // Ed25519 pubkey hex
contactX25519: string; // X25519 pubkey hex
username?: string;
alias?: string;
lastMessage?: string;
lastTime?: number;
unread: number;
}
// ─── Contact request ─────────────────────────────────────────────────────────
export interface ContactRequest {
from: string; // Ed25519 pubkey hex
x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity
username?: string;
intro: string; // plaintext intro (stored on-chain)
timestamp: number;
txHash: string;
}
// ─── Transaction ─────────────────────────────────────────────────────────────
export interface TxRecord {
hash: string;
type: string;
from: string;
to?: string;
amount?: number;
fee: number;
timestamp: number;
status: 'confirmed' | 'pending';
}
// ─── Node info ───────────────────────────────────────────────────────────────
export interface NetStats {
total_blocks: number;
total_txs: number;
peer_count: number;
chain_id: string;
}
export interface NodeSettings {
nodeUrl: string;
contractId: string; // username_registry contract
}

35
client-app/lib/utils.ts Normal file
View File

@@ -0,0 +1,35 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Format µT amount to human-readable string */
export function formatAmount(microTokens: number | undefined | null): string {
if (microTokens == null) return '—';
if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`;
if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`;
return `${microTokens} µT`;
}
/** Format unix seconds to relative time */
export function relativeTime(unixSeconds: number | undefined | null): string {
if (!unixSeconds) return '';
const diff = Date.now() / 1000 - unixSeconds;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return new Date(unixSeconds * 1000).toLocaleDateString();
}
/** Format unix seconds to HH:MM */
export function formatTime(unixSeconds: number | undefined | null): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
/** Generate a random nonce string */
export function randomId(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}

401
client-app/lib/ws.ts Normal file
View File

@@ -0,0 +1,401 @@
/**
* DChain WebSocket client — replaces balance / inbox / contacts polling with
* server-push. Matches `node/ws.go` exactly.
*
* Usage:
* const ws = getWSClient();
* ws.connect(); // idempotent
* const off = ws.subscribe('addr:ab12…', ev => { ... });
* // later:
* off(); // unsubscribe + stop handler
* ws.disconnect();
*
* Features:
* - Auto-reconnect with exponential backoff (1s → 30s cap).
* - Re-subscribes all topics after a reconnect.
* - `hello` frame exposes chain_id + tip_height for connection state UI.
* - Degrades silently if the endpoint returns 501 (old node without WS).
*/
import { getNodeUrl, onNodeUrlChange } from './api';
import { sign } from './crypto';
export type WSEventName =
| 'hello'
| 'block'
| 'tx'
| 'contract_log'
| 'inbox'
| 'typing'
| 'pong'
| 'error'
| 'subscribed'
| 'submit_ack'
| 'lag';
export interface WSFrame {
event: WSEventName;
data?: unknown;
topic?: string;
msg?: string;
chain_id?: string;
tip_height?: number;
/** Server-issued nonce in the hello frame; client signs it for auth. */
auth_nonce?: string;
// submit_ack fields
id?: string;
status?: 'accepted' | 'rejected';
reason?: string;
}
type Handler = (frame: WSFrame) => void;
class WSClient {
private ws: WebSocket | null = null;
private url: string | null = null;
private reconnectMs: number = 1000;
private closing: boolean = false;
/** topic → set of handlers interested in frames for this topic */
private handlers: Map<string, Set<Handler>> = new Map();
/** topics we want the server to push — replayed on every reconnect */
private wantedTopics: Set<string> = new Set();
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
/**
* Credentials used for auto-auth on every (re)connect. The signer runs on
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
* Without these, subscribe requests to scoped topics get rejected by the
* server; global topics (blocks, tx, …) still work unauthenticated.
*/
private authCreds: { pubKey: string; privKey: string } | null = null;
/** Current connection state (read-only for UI). */
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
getHelloInfo(): { chainId?: string; tipHeight?: number } {
return this.helloInfo;
}
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
this.connectionListeners.add(cb);
return () => this.connectionListeners.delete(cb) as unknown as void;
}
private fireConnectionChange(ok: boolean, err?: string) {
for (const cb of this.connectionListeners) {
try { cb(ok, err); } catch { /* noop */ }
}
}
/**
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
* (re)connect against the server-issued nonce so the connection is bound
* to this identity. Pass null to disable auth (only global topics will
* work — useful for observers).
*/
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
this.authCreds = creds;
// If we're already connected, kick off auth immediately.
if (creds && this.isConnected() && this.helloInfo.authNonce) {
this.sendAuth(this.helloInfo.authNonce);
}
}
/** Idempotent connect. Call once on app boot. */
connect(): void {
const base = getNodeUrl();
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
if (this.ws) {
const state = this.ws.readyState;
// Already pointing at this URL and connected / connecting — nothing to do.
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
return;
}
// URL changed (operator flipped nodes in settings) — tear down and
// re-dial. Existing subscriptions live in wantedTopics and will be
// replayed after the new onopen fires.
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
try { this.ws.close(); } catch { /* noop */ }
this.ws = null;
}
}
this.closing = false;
this.url = newURL;
try {
this.ws = new WebSocket(this.url);
} catch (e: any) {
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectMs = 1000; // reset backoff
this.fireConnectionChange(true);
// Replay all wanted subscriptions.
for (const topic of this.wantedTopics) {
this.sendRaw({ op: 'subscribe', topic });
}
};
this.ws.onmessage = (ev) => {
let frame: WSFrame;
try {
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
} catch {
return;
}
if (frame.event === 'hello') {
this.helloInfo = {
chainId: frame.chain_id,
tipHeight: frame.tip_height,
authNonce: frame.auth_nonce,
};
// Auto-authenticate if credentials are set. The server binds this
// connection to the signed pubkey so scoped subscriptions (addr:*,
// inbox:*) get through. On reconnect a new nonce is issued, so the
// auth dance repeats transparently.
if (this.authCreds && frame.auth_nonce) {
this.sendAuth(frame.auth_nonce);
}
}
// Dispatch to all handlers for any topic that could match this frame.
// We use a simple predicate: look at the frame to decide which topics it
// was fanned out to, then fire every matching handler.
for (const topic of this.topicsForFrame(frame)) {
const set = this.handlers.get(topic);
if (!set) continue;
for (const h of set) {
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
}
}
};
this.ws.onerror = (e: any) => {
this.fireConnectionChange(false, 'ws error');
};
this.ws.onclose = () => {
this.ws = null;
this.fireConnectionChange(false);
if (!this.closing) this.scheduleReconnect();
};
}
disconnect(): void {
this.closing = true;
if (this.ws) {
try { this.ws.close(); } catch { /* noop */ }
this.ws = null;
}
}
/**
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
* removes the handler. If multiple callers subscribe to the same topic,
* the server is only notified on the first and last caller.
*/
subscribe(topic: string, handler: Handler): () => void {
let set = this.handlers.get(topic);
if (!set) {
set = new Set();
this.handlers.set(topic, set);
}
set.add(handler);
// Notify server only on the first handler for this topic.
if (!this.wantedTopics.has(topic)) {
this.wantedTopics.add(topic);
if (this.isConnected()) {
this.sendRaw({ op: 'subscribe', topic });
} else {
this.connect(); // lazy-connect on first subscribe
}
}
return () => {
const s = this.handlers.get(topic);
if (!s) return;
s.delete(handler);
if (s.size === 0) {
this.handlers.delete(topic);
this.wantedTopics.delete(topic);
if (this.isConnected()) {
this.sendRaw({ op: 'unsubscribe', topic });
}
}
};
}
/** Force a keepalive ping. Useful for debugging. */
ping(): void {
this.sendRaw({ op: 'ping' });
}
/**
* Send a typing indicator to another user. Recipient is their X25519 pubkey
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
* fire and forget. Call on each keystroke but throttle to once per 2-3s
* at the caller side so we don't flood the WS with frames.
*/
sendTyping(recipientX25519: string): void {
if (!this.isConnected()) return;
try {
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
} catch { /* best-effort */ }
}
/**
* Submit a signed transaction over the WebSocket and resolve once the
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
* and gives the UI immediate accept/reject feedback.
*
* Rejects if:
* - WS is not connected (caller should fall back to HTTP)
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
* - No ack within `timeoutMs` (default 10 s)
*/
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
if (!this.isConnected()) {
return Promise.reject(new Error('WS not connected'));
}
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
return new Promise((resolve, reject) => {
const off = this.subscribe('$system', (frame) => {
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
off();
clearTimeout(timer);
if (frame.status === 'accepted') {
// `msg` carries the server-confirmed tx id.
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
} else {
reject(new Error(frame.reason || 'submit_tx rejected'));
}
});
const timer = setTimeout(() => {
off();
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
}, timeoutMs);
try {
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
} catch (e: any) {
off();
clearTimeout(timer);
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
}
});
}
// ── internals ───────────────────────────────────────────────────────────
private scheduleReconnect(): void {
if (this.closing) return;
const delay = Math.min(this.reconnectMs, 30_000);
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
setTimeout(() => {
if (!this.closing) this.connect();
}, delay);
}
private sendRaw(cmd: { op: string; topic?: string }): void {
if (!this.isConnected()) return;
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
}
/**
* Sign the server nonce with our Ed25519 private key and send the `auth`
* op. The server binds this connection to `authCreds.pubKey`; subsequent
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
*/
private sendAuth(nonce: string): void {
if (!this.authCreds || !this.isConnected()) return;
try {
const bytes = new TextEncoder().encode(nonce);
const sig = sign(bytes, this.authCreds.privKey);
this.ws!.send(JSON.stringify({
op: 'auth',
pubkey: this.authCreds.pubKey,
sig,
}));
} catch (e) {
console.warn('[ws] auth send failed:', e);
}
}
/**
* Given an incoming frame, enumerate every topic that handlers could have
* subscribed to and still be interested. This mirrors the fan-out logic in
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
*/
private topicsForFrame(frame: WSFrame): string[] {
switch (frame.event) {
case 'block':
return ['blocks'];
case 'tx': {
const d = frame.data as { from?: string; to?: string } | undefined;
const topics = ['tx'];
if (d?.from) topics.push('addr:' + d.from);
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
return topics;
}
case 'contract_log': {
const d = frame.data as { contract_id?: string } | undefined;
const topics = ['contract_log'];
if (d?.contract_id) topics.push('contract:' + d.contract_id);
return topics;
}
case 'inbox': {
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
// we mirror that here so both firehose listeners and address-scoped
// subscribers see the event.
const d = frame.data as { recipient_pub?: string } | undefined;
const topics = ['inbox'];
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
return topics;
}
case 'typing': {
// Server fans to `typing:<to>` only (the recipient).
const d = frame.data as { to?: string } | undefined;
return d?.to ? ['typing:' + d.to] : [];
}
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
// can listen for them via subscribe('$system', ...).
case 'hello':
case 'pong':
case 'error':
case 'subscribed':
case 'submit_ack':
case 'lag':
return ['$system'];
default:
return [];
}
}
}
let _singleton: WSClient | null = null;
/**
* Return the app-wide WebSocket client. Safe to call from any component;
* `.connect()` is idempotent.
*
* On first creation we register a node-URL listener so flipping the node
* in Settings tears down the existing socket and dials the new one — the
* user's active subscriptions (addr:*, inbox:*) replay automatically.
*/
export function getWSClient(): WSClient {
if (!_singleton) {
_singleton = new WSClient();
onNodeUrlChange(() => {
// Fire and forget — connect() is idempotent and handles stale URLs.
_singleton!.connect();
});
}
return _singleton;
}