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
250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|