Files
dchain/client-app/lib/feed.ts
vsecoder 5b64ef2560 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>
2026-04-18 19:43:55 +03:00

488 lines
16 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, 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;