/** * Profile screen — shows info about any address (yours or someone else's), * plus their post feed, follow/unfollow button, and basic counters. * * Routes: * /(app)/profile/ * * Two states: * - Known contact → open chat, show full info * - Unknown address → Twitter-style "discovery" profile: shows just the * address + posts + follow button. Useful when tapping an author from * the feed of someone you don't chat with. */ import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, ScrollView, Pressable, Alert, FlatList, ActivityIndicator, RefreshControl, } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import type { Contact } from '@/lib/types'; import { Avatar } from '@/components/Avatar'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; import { PostCard } from '@/components/feed/PostCard'; import { fetchAuthorPosts, fetchStats, followUser, unfollowUser, formatCount, type FeedPostItem, } from '@/lib/feed'; import { humanizeTxError } from '@/lib/api'; function shortAddr(a: string, n = 10): string { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } type Tab = 'posts' | 'info'; export default function ProfileScreen() { const insets = useSafeAreaInsets(); const { address } = useLocalSearchParams<{ address: string }>(); const contacts = useStore(s => s.contacts); const keyFile = useStore(s => s.keyFile); const contact = contacts.find(c => c.address === address); const [tab, setTab] = useState('posts'); const [posts, setPosts] = useState([]); const [likedSet, setLikedSet] = useState>(new Set()); const [loadingPosts, setLoadingPosts] = useState(true); const [refreshing, setRefreshing] = useState(false); // Follow state is optimistic + reconciled via on-chain query. For MVP // we keep a local-only flag that toggles immediately on tap; future: // query chain.Following(me) once on mount to seed accurate initial state. const [following, setFollowing] = useState(false); const [followingBusy, setFollowingBusy] = useState(false); const [copied, setCopied] = useState(null); const isMe = !!keyFile && keyFile.pub_key === address; const displayName = contact?.username ? `@${contact.username}` : contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '')); const loadPosts = useCallback(async (isRefresh = false) => { if (!address) return; if (isRefresh) setRefreshing(true); else setLoadingPosts(true); try { const items = await fetchAuthorPosts(address, 40); setPosts(items); if (keyFile) { 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); } setLikedSet(liked); } } catch { setPosts([]); } finally { setLoadingPosts(false); setRefreshing(false); } }, [address, keyFile]); useEffect(() => { if (tab === 'posts') loadPosts(false); }, [tab, loadPosts]); const copy = async (value: string, label: string) => { await Clipboard.setStringAsync(value); setCopied(label); setTimeout(() => setCopied(null), 1800); }; const openChat = () => { if (!address) return; router.replace(`/(app)/chats/${address}` as never); }; const onToggleFollow = async () => { if (!keyFile || !address || isMe || followingBusy) return; setFollowingBusy(true); const wasFollowing = following; setFollowing(!wasFollowing); try { if (wasFollowing) { await unfollowUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address }); } else { await followUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address }); } } catch (e: any) { setFollowing(wasFollowing); Alert.alert('Не удалось', humanizeTxError(e)); } finally { setFollowingBusy(false); } }; 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]); const onDeleted = useCallback((postID: string) => { setPosts(ps => ps.filter(p => p.post_id !== postID)); }, []); // ── Hero + follow button block ────────────────────────────────────── const Hero = ( {!isMe ? ( ({ paddingHorizontal: 18, paddingVertical: 9, borderRadius: 999, backgroundColor: following ? (pressed ? '#1a1a1a' : '#111111') : (pressed ? '#e7e7e7' : '#ffffff'), borderWidth: following ? 1 : 0, borderColor: '#1f1f1f', minWidth: 110, alignItems: 'center', })} > {followingBusy ? ( ) : ( {following ? 'Вы подписаны' : 'Подписаться'} )} ) : ( router.push('/(app)/settings' as never)} style={({ pressed }) => ({ paddingHorizontal: 18, paddingVertical: 9, borderRadius: 999, backgroundColor: pressed ? '#1a1a1a' : '#111111', borderWidth: 1, borderColor: '#1f1f1f', })} > Редактировать )} {displayName} {contact?.username && ( )} {shortAddr(address ?? '')} {/* Counters row — post count is derived from what we loaded; follower/ following counters would require chain.Followers / chain.Following HTTP exposure which isn't wired yet (Phase D). */} {formatCount(posts.length)} постов {/* Secondary actions: open chat + copy address */} {!isMe && contact && ( ({ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 10, borderRadius: 999, backgroundColor: pressed ? '#1a1a1a' : '#111111', borderWidth: 1, borderColor: '#1f1f1f', flexDirection: 'row', gap: 6, })} > Чат address && copy(address, 'address')} style={({ pressed }) => ({ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 10, borderRadius: 999, backgroundColor: pressed ? '#1a1a1a' : '#111111', borderWidth: 1, borderColor: '#1f1f1f', })} > {copied === 'address' ? 'Скопировано' : 'Копировать адрес'} )} ); // ── Tab strip ──────────────────────────────────────────────────────── const TabStrip = ( {(['posts', 'info'] as Tab[]).map(key => ( setTab(key)} style={{ flex: 1, alignItems: 'center', paddingVertical: 12, }} > {key === 'posts' ? 'Посты' : 'Инфо'} {tab === key && ( )} ))} ); // ── Content per tab ───────────────────────────────────────────────── if (tab === 'posts') { return (
router.back()} />} /> p.post_id} renderItem={({ item }) => ( )} ListHeaderComponent={ <> {Hero} {TabStrip} } refreshControl={ loadPosts(true)} tintColor="#1d9bf0" /> } ListEmptyComponent={ loadingPosts ? ( ) : ( Пока нет постов {isMe ? 'Нажмите на синюю кнопку в ленте, чтобы написать первый.' : 'Этот пользователь ещё ничего не публиковал.'} ) } /> ); } // Info tab return (
router.back()} />} /> {Hero} {TabStrip} {contact && ( <> )} ); } function InfoRow({ label, value, mono, accent, danger, }: { label: string; value: string; mono?: boolean; accent?: boolean; danger?: boolean; }) { const color = danger ? '#f0b35a' : accent ? '#1d9bf0' : '#ffffff'; return ( {label} {value} ); } // Silence unused-import lint for Contact type used only in helpers. const _contactType: Contact | null = null; void _contactType;