Files
dchain/client-app/app/(app)/profile/[address].tsx
vsecoder 5b64ef2560 feat(client): Twitter-style social feed UI (Phase C of v2.0.0)
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).

Feed screens

  app/(app)/feed.tsx — Feed tab
    - Three-way tab strip: Подписки / Для вас / В тренде backed by
      /feed/timeline, /feed/foryou, /feed/trending respectively
    - Default landing tab is "Для вас" — surfaces discovery without
      requiring the user to follow anyone first
    - FlatList with pull-to-refresh + viewability-driven view counter
      bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
    - Floating blue compose button → /compose
    - Per-post liked_by_me fetched in batches of 6 after list load

  app/(app)/compose.tsx — post composer modal
    - Fullscreen, Twitter-like header (✕ left, Опубликовать right)
    - Auto-focused multiline TextInput, 4000 char cap
    - Hashtag preview chips that auto-update as you type
    - expo-image-picker + expo-image-manipulator pipeline: resize to
      1080px max-dim, JPEG Q=50 (client-side first-pass compression
      before the mandatory server-side scrub)
    - Live fee estimate + balance guard with a confirmation modal
      ("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
    - Exif: false passed to ImagePicker as an extra privacy layer

  app/(app)/feed/[id].tsx — post detail
    - Full PostCard rendering + detailed info panel (views, likes,
      size, fee, hosting relay, hashtags as tappable chips)
    - Triggers bumpView on mount
    - 410 (on-chain soft-delete) routes back to the feed

  app/(app)/feed/tag/[tag].tsx — hashtag feed

  app/(app)/profile/[address].tsx — rebuilt
    - Twitter-ish profile: avatar, name, address short-form, post count
    - Posts | Инфо tab strip
    - Follow / Unfollow button for non-self profiles (optimistic UI)
    - Edit button on self profile → settings
    - Secondary actions (chat, copy address) when viewing a known contact

Supporting library

  lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
    - publishPost (POST /feed/publish, signed)
    - publishAndCommit (publish → on-chain CREATE_POST)
    - fetchPost / fetchStats / bumpView
    - fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
      fetchHashtag
    - buildCreatePostTx / buildDeletePostTx
    - buildFollowTx / buildUnfollowTx
    - buildLikePostTx / buildUnlikePostTx
    - likePost / unlikePost / followUser / unfollowUser / deletePost
      (high-level helpers that bundle build + submitTx)
    - formatFee, formatRelativeTime, formatCount — Twitter-like display
      helpers

  components/feed/PostCard.tsx — core card component
    - Memoised for performance (N-row re-render on every like elsewhere
      would cost a lot otherwise)
    - Optimistic like toggle with heart-bounce spring animation
    - Hashtag highlighting in body text (tappable → hashtag feed)
    - Long-press context menu (Delete, owner-only)
    - Views / likes / share-link / reply icons in footer row

Navigation cleanup

  - NavBar: removed the SOON pill on the Feed tab (it's shipped now)
  - (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
  - AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
    sub-routes so back-swipe-right closes them

Channel removal (client side)

  - lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
    'channel' flag removed. `kind` field kept for backward compat with
    existing AsyncStorage records.
  - lib/devSeed.ts: dropped the 5 channel seed contacts.
  - components/ChatTile.tsx: removed channel kindIcon branch.

Dependencies

  - expo-image-manipulator added for client-side image compression.
  - expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
    that API to the legacy sub-path; the new streaming API isn't yet
    stable).

Type check

  - npx tsc --noEmit — clean, 0 errors.

Next (not in this commit)

  - Direct attachment-bytes endpoint on the server so post-detail can
    actually render the image (currently shows placeholder with URL)
  - Cross-relay body fetch via /api/relays + hosting_relay pubkey
  - Mentions (@username) with notifications
  - Full-text search

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:43:55 +03:00

442 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/<ed25519-hex>
*
* 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<Tab>('posts');
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(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<string | null>(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<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);
}
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 = (
<View style={{ paddingHorizontal: 14, paddingTop: 16, paddingBottom: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
<Avatar name={displayName} address={address} size={72} />
<View style={{ flex: 1 }} />
{!isMe ? (
<Pressable
onPress={onToggleFollow}
disabled={followingBusy}
style={({ pressed }) => ({
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 ? (
<ActivityIndicator
size="small"
color={following ? '#ffffff' : '#000000'}
/>
) : (
<Text
style={{
color: following ? '#ffffff' : '#000000',
fontWeight: '700',
fontSize: 13,
}}
>
{following ? 'Вы подписаны' : 'Подписаться'}
</Text>
)}
</Pressable>
) : (
<Pressable
onPress={() => router.push('/(app)/settings' as never)}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Редактировать
</Text>
</Pressable>
)}
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 14 }}>
<Text style={{ color: '#ffffff', fontSize: 22, fontWeight: '800' }}>
{displayName}
</Text>
{contact?.username && (
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
)}
</View>
<Text style={{ color: '#6a6a6a', fontSize: 12, marginTop: 2 }}>
{shortAddr(address ?? '')}
</Text>
{/* 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). */}
<View style={{ flexDirection: 'row', marginTop: 12, gap: 18 }}>
<Text style={{ color: '#ffffff', fontSize: 13 }}>
<Text style={{ fontWeight: '700' }}>{formatCount(posts.length)}</Text>
<Text style={{ color: '#6a6a6a' }}> постов</Text>
</Text>
</View>
{/* Secondary actions: open chat + copy address */}
{!isMe && contact && (
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
<Pressable
onPress={openChat}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: 10, borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', gap: 6,
})}
>
<Ionicons name="chatbubble-outline" size={14} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>
Чат
</Text>
</Pressable>
<Pressable
onPress={() => address && copy(address, 'address')}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: 10, borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>
{copied === 'address' ? 'Скопировано' : 'Копировать адрес'}
</Text>
</Pressable>
</View>
)}
</View>
);
// ── Tab strip ────────────────────────────────────────────────────────
const TabStrip = (
<View
style={{
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#141414',
marginTop: 14,
}}
>
{(['posts', 'info'] as Tab[]).map(key => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={{
flex: 1,
alignItems: 'center',
paddingVertical: 12,
}}
>
<Text
style={{
color: tab === key ? '#ffffff' : '#6a6a6a',
fontWeight: tab === key ? '700' : '500',
fontSize: 13,
}}
>
{key === 'posts' ? 'Посты' : 'Инфо'}
</Text>
{tab === key && (
<View style={{
marginTop: 6,
width: 48, height: 3, borderRadius: 1.5,
backgroundColor: '#1d9bf0',
}} />
)}
</Pressable>
))}
</View>
);
// ── Content per tab ─────────────────────────────────────────────────
if (tab === 'posts') {
return (
<View style={{ flex: 1, backgroundColor: '#000000' }}>
<Header
title="Профиль"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
onDeleted={onDeleted}
/>
)}
ListHeaderComponent={
<>
{Hero}
{TabStrip}
</>
}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadPosts(true)}
tintColor="#1d9bf0"
/>
}
ListEmptyComponent={
loadingPosts ? (
<View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : (
<View style={{
paddingVertical: 60,
paddingHorizontal: 32,
alignItems: 'center',
}}>
<Ionicons name="newspaper-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
Пока нет постов
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 6, textAlign: 'center' }}>
{isMe
? 'Нажмите на синюю кнопку в ленте, чтобы написать первый.'
: 'Этот пользователь ещё ничего не публиковал.'}
</Text>
</View>
)
}
/>
</View>
);
}
// Info tab
return (
<View style={{ flex: 1, backgroundColor: '#000000' }}>
<Header
title="Профиль"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
<ScrollView>
{Hero}
{TabStrip}
<View style={{ paddingHorizontal: 14, paddingTop: 14 }}>
<View
style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
<InfoRow label="Адрес" value={shortAddr(address ?? '')} mono />
{contact && (
<>
<InfoRow
label="Ключ шифрования"
value={contact.x25519Pub ? shortAddr(contact.x25519Pub) : 'не опубликован'}
mono={!!contact.x25519Pub}
danger={!contact.x25519Pub}
/>
<InfoRow label="Добавлен" value={new Date(contact.addedAt).toLocaleDateString()} />
</>
)}
</View>
</View>
<View style={{ height: 40 + insets.bottom }} />
</ScrollView>
</View>
);
}
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 (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: '#1f1f1f',
}}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text
style={{
color, fontSize: 13,
fontFamily: mono ? 'monospace' : undefined,
fontWeight: '600',
}}
numberOfLines={1}
>
{value}
</Text>
</View>
);
}
// Silence unused-import lint for Contact type used only in helpers.
const _contactType: Contact | null = null; void _contactType;