Files
dchain/client-app/app/(app)/feed/index.tsx
vsecoder f688f45739 fix(feed): breathing room around divider + FAB back to right/bottom corner
- PostSeparator was a bare 1px line flush against both neighbouring
  cards, so the seam looked like card contact instead of a real
  break. Wrapped it in a 12px vertical padding — now there's 12px
  blank above the line, 1px line, 12px blank below. Trimmed the
  card's own paddingVertical back down since the separator now owns
  the inter-post breathing room.
- FAB was lifted to bottom: max(insets.bottom, 8) + 70 in the previous
  commit which put it far too high (a leftover from the original
  experiment with the absoluteFill wrapper). User wants 12px above
  the NavBar and 12px from the right edge. The Feed screen's
  container ends at the NavBar top (enforced by (app)/_layout.tsx's
  outer <View flex:1> + NavBar sibling), so a simple `right: 12,
  bottom: 12` on position:absolute lands the button exactly there on
  every device. Removed the now-unnecessary absoluteFill wrapper too.

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

340 lines
12 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.

/**
* Feed tab — Twitter-style timeline with three sources:
*
* Подписки → /feed/timeline?follower=me (posts from people I follow)
* Для вас → /feed/foryou?pub=me (recommendations)
* В тренде → /feed/trending?window=24 (most-engaged in last 24h)
*
* Floating compose button (bottom-right) → /(app)/compose modal.
*
* Uses a single FlatList per tab with pull-to-refresh + optimistic
* local updates. Stats (likes, likedByMe) are fetched once per refresh
* and piggy-backed onto each PostCard via props; the card does the
* optimistic toggle locally until the next refresh reconciles.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
View, Text, FlatList, Pressable, RefreshControl, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { TabHeader } from '@/components/TabHeader';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import {
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
type FeedPostItem,
} from '@/lib/feed';
import { getDevSeedFeed } from '@/lib/devSeedFeed';
type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = {
following: 'Подписки',
foryou: 'Для вас',
trending: 'В тренде',
};
export default function FeedScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [tab, setTab] = useState<TabKey>('foryou'); // default: discovery
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Guard against rapid tab switches overwriting each other's results.
const requestRef = useRef(0);
const loadPosts = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
setError(null);
const seq = ++requestRef.current;
try {
let items: FeedPostItem[] = [];
switch (tab) {
case 'following':
items = await fetchTimeline(keyFile.pub_key, 40);
break;
case 'foryou':
items = await fetchForYou(keyFile.pub_key, 40);
break;
case 'trending':
items = await fetchTrending(24, 40);
break;
}
if (seq !== requestRef.current) return; // stale response
// Dev-only fallback: if the node has no real posts yet, surface
// synthetic ones so we can scroll + tap. Stripped from production.
if (items.length === 0) {
items = getDevSeedFeed();
}
setPosts(items);
// Batch-fetch liked_by_me (bounded concurrency — 6 at a time).
const liked = new Set<string>();
const chunks = chunk(items, 6);
for (const group of chunks) {
const results = await Promise.all(
group.map(p => fetchStats(p.post_id, keyFile.pub_key)),
);
results.forEach((s, i) => {
if (s?.liked_by_me) liked.add(group[i].post_id);
});
}
if (seq !== requestRef.current) return;
setLikedSet(liked);
} catch (e: any) {
if (seq !== requestRef.current) return;
const msg = String(e?.message ?? e);
// Network / 404 is benign — node just unreachable or empty. In __DEV__
// fall back to synthetic seed posts so the scroll / tap UI stays
// testable; in production this path shows the empty state.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts(getDevSeedFeed());
} else {
setError(msg);
}
} finally {
if (seq !== requestRef.current) return;
setLoading(false);
setRefreshing(false);
}
}, [keyFile, tab]);
useEffect(() => { loadPosts(false); }, [loadPosts]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const stats = await fetchStats(postID, keyFile.pub_key);
if (!stats) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: stats.likes, views: stats.views }
: p));
setLikedSet(s => {
const next = new Set(s);
if (stats.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));
}, []);
// View counter: fire bumpView once when a card scrolls into view.
const viewedRef = useRef<Set<string>>(new Set());
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ item: FeedPostItem; isViewable: boolean }> }) => {
for (const { item, isViewable } of viewableItems) {
if (isViewable && !viewedRef.current.has(item.post_id)) {
viewedRef.current.add(item.post_id);
bumpView(item.post_id);
}
}
}).current;
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60, minimumViewTime: 1000 }).current;
const emptyHint = useMemo(() => {
switch (tab) {
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.';
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.';
case 'trending': return 'В этой ленте пока тихо.';
}
}, [tab]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" />
{/* Tab strip — три таба, равномерно распределены по ширине
(justifyContent: space-between). Каждый Pressable hug'ает
свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к
своему лейблу, что даёт воздух между ними. Индикатор активной
вкладки — тонкая полоска под лейблом. */}
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: '#1f1f1f',
}}
>
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={({ pressed }) => ({
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 6,
opacity: pressed ? 0.6 : 1,
})}
>
<Text
style={{
color: tab === key ? '#ffffff' : '#6a6a6a',
fontWeight: tab === key ? '700' : '500',
fontSize: 15,
letterSpacing: -0.1,
}}
>
{TAB_LABELS[key]}
</Text>
<View
style={{
marginTop: 10,
width: tab === key ? 28 : 0,
height: 3,
borderRadius: 1.5,
backgroundColor: '#1d9bf0',
}}
/>
</Pressable>
))}
</View>
{/* Feed list */}
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
onDeleted={onDeleted}
/>
)}
ItemSeparatorComponent={PostSeparator}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadPosts(true)}
tintColor="#1d9bf0"
/>
}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : error ? (
<EmptyState
icon="alert-circle-outline"
title="Не удалось загрузить ленту"
subtitle={error}
onRetry={() => loadPosts(false)}
/>
) : (
<EmptyState
icon="newspaper-outline"
title="Здесь пока пусто"
subtitle={emptyHint}
/>
)
}
contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined}
/>
{/* Floating compose button — pinned 12px from the right edge of
the screen and 12px above the NavBar top. The Feed screen's
container ends at the NavBar top (see (app)/_layout.tsx), so
a plain bottom: 12 measures from exactly that edge. */}
<Pressable
onPress={() => router.push('/(app)/compose' as never)}
style={({ pressed }) => ({
position: 'absolute',
right: 12,
bottom: 12,
width: 56, height: 56,
borderRadius: 28,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 6,
elevation: 8,
})}
>
<Ionicons name="create-outline" size={24} color="#ffffff" />
</Pressable>
</View>
);
}
// ── Empty state ─────────────────────────────────────────────────────────
function EmptyState({
icon, title, subtitle, onRetry,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
subtitle?: string;
onRetry?: () => void;
}) {
return (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<View
style={{
width: 64, height: 64, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 14,
}}
>
<Ionicons name={icon} size={28} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
{title}
</Text>
{subtitle && (
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
{subtitle}
</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
style={({ pressed }) => ({
marginTop: 16,
paddingHorizontal: 20, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Попробовать снова
</Text>
</Pressable>
)}
</View>
);
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}