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:
487
client-app/lib/feed.ts
Normal file
487
client-app/lib/feed.ts
Normal 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;
|
||||
Reference in New Issue
Block a user