Feed list padding
FlatList had no inner padding so the first post bumped against the
tab strip and the last post against the NavBar. Added paddingTop: 8
/ paddingBottom: 24 on contentContainerStyle in both /feed and
/feed/tag/[tag] — first card now has a clear top gap, last card
doesn't get hidden behind the FAB or NavBar.
Share-to-chat flow
Replaces the placeholder share button (which showed an Alert with
the post URL) with a real "forward to chats" flow modeled on VK's
shared-wall-post embed.
New modules
lib/forwardPost.ts — encodePostRef / tryParsePostRef +
forwardPostToContacts(). Serialises a
feed post into a tiny JSON payload that
rides the same encrypted envelope as any
chat message; decode side distinguishes
"post_ref" payloads from regular text by
trying JSON.parse on decrypted text.
Mirrors the sent message into the sender's
local history so they see "you shared
this" in the chat they forwarded to.
components/feed/ShareSheet.tsx
— bottom-sheet picker. Multi-select
contacts via tick-box, search by
username / alias / address prefix.
"Send (N)" dispatches N parallel
encrypted envelopes. Contacts with no
X25519 key are filtered out (can't
encrypt for them).
components/chat/PostRefCard.tsx
— compact embedded-post card for chat
bubbles. Ribbon "ПОСТ" label +
author + 3-line excerpt + "с фото"
indicator. Tap → /(app)/feed/{id} full
post detail. Palette switches between
blue-bubble-friendly and peer-bubble-
friendly depending on bubble side.
Message pipeline
lib/types.ts — Message.postRef optional field added.
text stays "" when the message is a
post-ref (nothing to render as plain text).
hooks/useMessages.ts + hooks/useGlobalInbox.ts
— post decryption of every inbound envelope
runs through tryParsePostRef; matching
messages get the postRef attached instead
of the raw JSON in .text.
components/chat/MessageBubble.tsx
— renders PostRefCard inside the bubble when
msg.postRef is set. Other bubble features
(reply quote, attachment preview, text)
still work around it.
PostCard
- share icon now opens <ShareSheet>; the full-URL placeholder is
gone. ShareSheet is embedded at the PostCard level so each card
owns its own sheet state (avoids modal-stacking issues).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
/**
|
||
* 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 }
|
||
: { 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 }}>
|
||
Попробовать снова
|
||
</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;
|
||
}
|