feat: resource caps, Saved Messages, author walls, docs for node bring-up

Node flags (cmd/node/main.go):
  --max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
  --feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
  --chain-disk-limit-mb — advisory watcher (can't reject blocks without
  breaking consensus; logs WARN every minute)

Client — Saved Messages (self-chat):
  - Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
  - Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
  - Empty state rendered outside inverted FlatList — fixes the mirrored
    "say hi…" on Android RTL-aware layout builds
  - PostCard shows "You" for own posts instead of the self-contact alias

Client — user walls:
  - New route /(app)/feed/author/[pub] with infinite-scroll via
    `created_at` cursor and pull-to-refresh
  - Profile screen gains "View posts" button (universal) next to
    "Open chat" (contact-only)

Feed pipeline:
  - Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
    so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
  - ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
    preserved in the HTTP layer
  - FeedMailbox quota + DiskUsage surface — supports new CLI flag

README:
  - Step-by-step "first node / joiner" section on the landing page,
    full flag tables incl. the new resource-cap group, minimal
    checklists for open/private/low-end deployments
This commit is contained in:
vsecoder
2026-04-19 13:14:47 +03:00
parent e6f3d2bcf8
commit a75cbcd224
18 changed files with 870 additions and 102 deletions

View File

@@ -0,0 +1,249 @@
/**
* Author wall — timeline of every post by a single author, newest first.
*
* Route: /(app)/feed/author/[pub]
*
* Entry points:
* - Profile screen "View posts" button.
* - Tapping the author name/avatar inside a PostCard.
*
* Backend: GET /feed/author/{pub}?limit=N[&before=ts]
* — chain-authoritative, returns FeedPostItem[] ordered newest-first.
*
* Pagination: infinite-scroll via onEndReached → appends the next page
* anchored on the oldest timestamp we've seen. Safe to over-fetch because
* the relay caps `limit`.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, FlatList, RefreshControl, ActivityIndicator, Pressable,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { Avatar } from '@/components/Avatar';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchAuthorPosts, fetchStats, type FeedPostItem } from '@/lib/feed';
import { getIdentity, type IdentityInfo } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const PAGE = 30;
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function AuthorWallScreen() {
const insets = useSafeAreaInsets();
const { pub } = useLocalSearchParams<{ pub: string }>();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const contact = contacts.find(c => c.address === pub);
const isMe = !!keyFile && keyFile.pub_key === pub;
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const seq = useRef(0);
// Identity — for the header's username / avatar seed. Best-effort; the
// screen still works without it.
useEffect(() => {
if (!pub) return;
let cancelled = false;
getIdentity(pub).then(id => { if (!cancelled) setIdentity(id); }).catch(() => {});
return () => { cancelled = true; };
}, [pub]);
const loadLikedFor = useCallback(async (items: FeedPostItem[]) => {
if (!keyFile) return new Set<string>();
const liked = new Set<string>();
for (const p of items) {
const s = await fetchStats(p.post_id, keyFile.pub_key);
if (s?.liked_by_me) liked.add(p.post_id);
}
return liked;
}, [keyFile]);
const load = useCallback(async (isRefresh = false) => {
if (!pub) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
const id = ++seq.current;
try {
const items = await fetchAuthorPosts(pub, { limit: PAGE });
if (id !== seq.current) return;
setPosts(items);
setExhausted(items.length < PAGE);
const liked = await loadLikedFor(items);
if (id !== seq.current) return;
setLikedSet(liked);
} catch {
if (id !== seq.current) return;
setPosts([]);
setExhausted(true);
} finally {
if (id !== seq.current) return;
setLoading(false);
setRefreshing(false);
}
}, [pub, loadLikedFor]);
useEffect(() => { load(false); }, [load]);
const loadMore = useCallback(async () => {
if (!pub || loadingMore || exhausted || loading) return;
const oldest = posts[posts.length - 1];
if (!oldest) return;
setLoadingMore(true);
try {
const more = await fetchAuthorPosts(pub, { limit: PAGE, before: oldest.created_at });
// De-dup by post_id — defensive against boundary overlap.
const known = new Set(posts.map(p => p.post_id));
const fresh = more.filter(p => !known.has(p.post_id));
if (fresh.length === 0) { setExhausted(true); return; }
setPosts(prev => [...prev, ...fresh]);
if (more.length < PAGE) setExhausted(true);
const liked = await loadLikedFor(fresh);
setLikedSet(set => {
const next = new Set(set);
liked.forEach(v => next.add(v));
return next;
});
} catch {
// Swallow — user can pull-to-refresh to recover.
} finally {
setLoadingMore(false);
}
}, [pub, posts, loadingMore, exhausted, loading, loadLikedFor]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const s = await fetchStats(postID, keyFile.pub_key);
if (!s) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: s.likes, views: s.views } : p));
setLikedSet(set => {
const next = new Set(set);
if (s.liked_by_me) next.add(postID); else next.delete(postID);
return next;
});
}, [keyFile]);
// "Saved Messages" is a messaging-app label and has no place on a public
// wall — for self we fall back to the real handle (@username if claimed,
// else short-addr), same as any other author.
const displayName = isMe
? (identity?.nickname ? `@${identity.nickname}` : 'You')
: contact?.username
? `@${contact.username}`
: (contact?.alias && contact.alias !== 'Saved Messages')
? contact.alias
: (identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6));
const handle = identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={() => pub && router.push(`/(app)/profile/${pub}` as never)}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={displayName} address={pub} size={28} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff', fontSize: 15, fontWeight: '700', letterSpacing: -0.2,
}}
>
{displayName}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
{handle !== displayName ? handle : 'Wall'}
</Text>
</View>
</Pressable>
}
/>
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
/>
)}
ItemSeparatorComponent={PostSeparator}
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
onEndReachedThreshold={0.6}
onEndReached={loadMore}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
ListFooterComponent={
loadingMore ? (
<View style={{ paddingVertical: 18 }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : null
}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<Ionicons name="document-text-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
{isMe ? "You haven't posted yet" : 'No posts yet'}
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
{isMe
? 'Tap the compose button on the feed tab to publish your first post.'
: 'This user hasn\'t published any posts on this chain.'}
</Text>
</View>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
</View>
);
}