/** * 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([]); const [likedSet, setLikedSet] = useState>(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(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(); const liked = new Set(); 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 (
safeBack()} />} title={ pub && router.push(`/(app)/profile/${pub}` as never)} hitSlop={4} style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }} > {displayName} {handle !== displayName ? handle : 'Wall'} } /> p.post_id} renderItem={({ item }) => ( )} ItemSeparatorComponent={PostSeparator} initialNumToRender={10} maxToRenderPerBatch={8} windowSize={7} removeClippedSubviews onEndReachedThreshold={0.6} onEndReached={loadMore} refreshControl={ load(true)} tintColor="#1d9bf0" /> } ListFooterComponent={ loadingMore ? ( ) : null } ListEmptyComponent={ loading ? ( ) : ( {isMe ? "You haven't posted yet" : 'No posts yet'} {isMe ? 'Tap the compose button on the feed tab to publish your first post.' : 'This user hasn\'t published any posts on this chain.'} ) } contentContainerStyle={ posts.length === 0 ? { flexGrow: 1 } : { paddingTop: 8, paddingBottom: 24 } } /> ); }