Files
dchain/client-app/lib/feed.ts
vsecoder 6425b5cffb feat(feed/chat): lazy-render + pagination for long scrolls
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>
2026-04-18 21:51:43 +03:00

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;