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:
249
client-app/app/(app)/feed/author/[pub].tsx
Normal file
249
client-app/app/(app)/feed/author/[pub].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user