/** * 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:::" 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(path: string): Promise { const res = await fetch(`${getNodeUrl()}${path}`); if (!res.ok) { throw new Error(`GET ${path} → ${res.status}`); } return res.json() as Promise; } async function postJSON(path: string, body: unknown): Promise { 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; } // ─── 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 { 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 { 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 { 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:::" 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('/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 { 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 { try { return await getJSON(`/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 { try { const qs = me ? `?me=${me}` : ''; return await getJSON(`/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 { try { await postJSON(`/feed/post/${postID}/view`, undefined); } catch { /* ignore */ } } export async function fetchAuthorPosts( pub: string, opts: { limit?: number; before?: number } = {}, ): Promise { const limit = opts.limit ?? 30; const qs = opts.before ? `?limit=${limit}&before=${opts.before}` : `?limit=${limit}`; const resp = await getJSON(`/feed/author/${pub}${qs}`); return resp.posts ?? []; } export async function fetchTimeline( followerPub: string, opts: { limit?: number; before?: number } = {}, ): Promise { const limit = opts.limit ?? 30; let qs = `?follower=${followerPub}&limit=${limit}`; if (opts.before) qs += `&before=${opts.before}`; const resp = await getJSON(`/feed/timeline${qs}`); return resp.posts ?? []; } export async function fetchForYou(pub: string, limit = 30): Promise { const resp = await getJSON(`/feed/foryou?pub=${pub}&limit=${limit}`); return resp.posts ?? []; } export async function fetchTrending(windowHours = 24, limit = 30): Promise { const resp = await getJSON(`/feed/trending?window=${windowHours}&limit=${limit}`); return resp.posts ?? []; } export async function fetchHashtag(tag: string, limit = 30): Promise { const cleanTag = tag.replace(/^#/, ''); const resp = await getJSON(`/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;