Server pagination
- blockchain.PostsByAuthor signature extended with beforeTs int64;
passing 0 keeps the previous "everything, newest first" behaviour,
non-zero skips posts with CreatedAt >= beforeTs so clients can
paginate older results.
- node.FeedConfig.PostsByAuthor callback type updated; the two
/feed endpoints that use it (timeline + author) now accept
`?before=<unix_seconds>` and forward it through. /feed/author
limit default dropped from 50 to 30 to match the client's page
size.
- node/api_common.go: new queryInt64 helper for parsing the cursor
param safely (matches the queryInt pattern already used).
Client infinite scroll (Feed tab)
- lib/feed.ts: fetchTimeline / fetchAuthorPosts accept
`{limit?, before?}` options. Old signatures still work for other
callers (fetchForYou / fetchTrending / fetchHashtag) — those are
ranked feeds that don't have a stable cursor so they stay
single-shot.
- feed/index.tsx: tracks loadingMore / exhausted state. onEndReached
(threshold 0.6) fires loadMore() which fetches the next 20 posts
using the oldest currently-loaded post's created_at as `before`.
Deduplicates on post_id before appending. Stops when the server
returns < PAGE_SIZE items. ListFooterComponent shows a small
spinner during paginated fetches.
- FlatList lazy-render tuning on all feed lists (index + hashtag):
initialNumToRender:10, maxToRenderPerBatch:8, windowSize:7,
removeClippedSubviews — first paint stays quick even with 100+
posts loaded.
Chat lazy render
- chats/[id].tsx FlatList: initialNumToRender:25 (~1.5 screens),
maxToRenderPerBatch:12, windowSize:10, removeClippedSubviews.
Keeps initial chat open snappy on conversations with thousands
of messages; RN re-renders a small window around the viewport
and drops the rest.
Tests
- chain_test.go updated for new PostsByAuthor signature.
- All 7 Go packages green.
- tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
/**
|
|
* 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, opts: { limit?: number; before?: number } = {},
|
|
): Promise<FeedPostItem[]> {
|
|
const limit = opts.limit ?? 30;
|
|
const qs = opts.before
|
|
? `?limit=${limit}&before=${opts.before}`
|
|
: `?limit=${limit}`;
|
|
const resp = await getJSON<TimelineResponse>(`/feed/author/${pub}${qs}`);
|
|
return resp.posts ?? [];
|
|
}
|
|
|
|
export async function fetchTimeline(
|
|
followerPub: string, opts: { limit?: number; before?: number } = {},
|
|
): Promise<FeedPostItem[]> {
|
|
const limit = opts.limit ?? 30;
|
|
let qs = `?follower=${followerPub}&limit=${limit}`;
|
|
if (opts.before) qs += `&before=${opts.before}`;
|
|
const resp = await getJSON<TimelineResponse>(`/feed/timeline${qs}`);
|
|
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;
|