Files
dchain/client-app/app/(app)/feed/index.tsx
vsecoder f7a849ddcb chore(client): translate all user-visible strings to English
Mixed-language UI was confusing — onboarding said "Why DChain / How it
works / Your keys" in English headings but feature descriptions and
CTAs were in Russian; compose's confirm dialog was Russian; feed tabs
were Russian; error messages in humanizeTxError were Russian.
Everything user-facing is now English.

Files touched (only string literals, not comments):
  app/index.tsx              onboarding slides + CTA buttons
  app/(app)/compose.tsx      composer alerts, header button, placeholder,
                             attachment-size hint
  app/(app)/feed/index.tsx   tab labels (Following/For you/Trending),
                             empty-state hints, retry button
  app/(app)/feed/[id].tsx    post detail header + stats rows (Views,
                             Likes, Size, Paid to publish, Hosted on,
                             Hashtags)
  app/(app)/feed/tag/[tag].tsx  empty-state copy
  app/(app)/profile/[address].tsx  Profile header, Follow/Following,
                             Edit, Open chat, Address, Copied, Encryption,
                             Added, Members, unknown-contact hint
  app/(app)/new-contact.tsx  Search title, placeholder, Search button,
                             empty-state hint, E2E-ready indicator,
                             Intro label + placeholder, fee-tier labels
                             (Min / Standard / Priority), Send request,
                             Insufficient-balance alert, Request-sent
                             alert
  app/(app)/requests.tsx     Notifications title, empty-state, Accept /
                             Decline buttons, decline-confirm alert,
                             "wants to add you" line
  components/SearchBar.tsx   default placeholder
  components/feed/PostCard.tsx  long-press menu (Delete post, confirm,
                             Actions / Cancel)
  components/feed/ShareSheet.tsx  sheet title, contact-search placeholder,
                             empty state, Select contacts / Send button,
                             plural helper rewritten for English
  components/chat/PostRefCard.tsx  "POST" ribbon, "photo" indicator
  lib/api.ts                 humanizeTxError (rate-limit, clock skew,
                             bad signature, 400/5xx/network-error
                             messages)
  lib/dates.ts               dateBucket now returns Today/Yesterday/
                             "Jun 17, 2025"; month array switched to
                             English short forms

Code comments left in Russian intentionally — they're developer
context, not user-facing. This commit is purely display-string.

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

425 lines
14 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';
type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = {
following: 'Following',
foryou: 'For you',
trending: '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 [loadingMore, setLoadingMore] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [error, setError] = useState<string | null>(null);
const PAGE_SIZE = 20;
// 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);
setExhausted(false);
const seq = ++requestRef.current;
try {
let items: FeedPostItem[] = [];
switch (tab) {
case 'following':
items = await fetchTimeline(keyFile.pub_key, { limit: PAGE_SIZE });
break;
case 'foryou':
items = await fetchForYou(keyFile.pub_key, PAGE_SIZE);
break;
case 'trending':
items = await fetchTrending(24, PAGE_SIZE);
break;
}
if (seq !== requestRef.current) return; // stale response
setPosts(items);
// If the server returned fewer than PAGE_SIZE, we already have
// everything — disable further paginated fetches.
if (items.length < PAGE_SIZE) setExhausted(true);
// 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. Show
// the empty-state; the catch block above already cleared error
// on benign messages. Production treats this identically.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts([]);
setExhausted(true);
} else {
setError(msg);
}
} finally {
if (seq !== requestRef.current) return;
setLoading(false);
setRefreshing(false);
}
}, [keyFile, tab]);
/**
* loadMore — paginate older posts when the user scrolls to the end
* of the list. Only the "following" and "foryou"/trending-less-useful
* paths actually support server-side pagination via the `before`
* cursor; foryou/trending return their ranked top-N which is by
* design not paginated (users very rarely scroll past 20 hot posts).
*
* We key the next page off the oldest post currently in state. If
* the server returns less than PAGE_SIZE items, we mark the list as
* exhausted to stop further fetches.
*/
const loadMore = useCallback(async () => {
if (!keyFile || loadingMore || exhausted || refreshing || loading) return;
if (posts.length === 0) return;
// foryou / trending are ranked, not ordered — no stable cursor to
// paginate against in v2.0.0. Skip.
if (tab === 'foryou' || tab === 'trending') return;
const oldest = posts[posts.length - 1];
const before = oldest?.created_at;
if (!before) return;
setLoadingMore(true);
const seq = requestRef.current; // don't bump — this is additive
try {
const next = await fetchTimeline(keyFile.pub_key, {
limit: PAGE_SIZE, before,
});
if (seq !== requestRef.current) return;
if (next.length === 0) {
setExhausted(true);
return;
}
// Dedup by post_id (could overlap on the boundary ts).
setPosts(prev => {
const have = new Set(prev.map(p => p.post_id));
const merged = [...prev];
for (const p of next) {
if (!have.has(p.post_id)) merged.push(p);
}
return merged;
});
if (next.length < PAGE_SIZE) setExhausted(true);
} catch {
// Don't escalate to error UI for pagination failures — just stop.
setExhausted(true);
} finally {
setLoadingMore(false);
}
}, [keyFile, loadingMore, exhausted, refreshing, loading, posts, 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 'Follow someone to see their posts here.';
case 'foryou': return 'No recommendations yet — come back later.';
case 'trending': return 'Nothing trending yet.';
}
}, [tab]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Feed" />
{/* 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}
onEndReached={loadMore}
onEndReachedThreshold={0.6}
ListFooterComponent={
loadingMore ? (
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" size="small" />
</View>
) : null
}
// Lazy-render tuning: start with one viewport's worth of posts,
// keep a small window around the visible area. Works together
// with onEndReached pagination for smooth long-feed scroll.
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
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="Couldn't load feed"
subtitle={error}
onRetry={() => loadPosts(false)}
/>
) : (
<EmptyState
icon="newspaper-outline"
title="Nothing to show yet"
subtitle={emptyHint}
/>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
{/* Floating compose button.
*
* Pressable's dynamic-function style sometimes drops absolute
* positioning on re-render on some RN versions — we've seen the
* button slide to the left edge after the first render. Wrap it
* in a plain absolute-positioned View so positioning lives on a
* stable element; the Pressable inside only declares its size
* and visuals. The parent Feed screen's container ends at the
* NavBar top (see (app)/_layout.tsx), so bottom: 12 means 12px
* above the NavBar on every device. */}
<View
pointerEvents="box-none"
style={{
position: 'absolute',
right: 12,
bottom: 12,
}}
>
<Pressable
onPress={() => router.push('/(app)/compose' as never)}
style={({ pressed }) => ({
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>
</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 }}>
Try again
</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;
}