fix(feed): card spacing, action-row distribution, tab strip, detail inset

- PostCard rows got cramped paddings and a near-invisible divider.
  Increased paddingTop 12→16, paddingBottom 12→18, paddingHorizontal
  14→16; divider colour #141414→#222222 so the seam between posts is
  legible on OLED blacks.
- Action row (chat / ❤ / view / share) used a fixed gap:32 + spacer.
  Reworked to four flex:1 cells with justifyContent: space-between,
  so the first three icons distribute evenly across the row and share
  pins to the right edge. Matches Twitter's layout where each action
  occupies a quarter of the row regardless of label width.
- Feed tab strip (Подписки / Для вас / В тренде) used flex:1 +
  gap:10 which bunched the three labels together visually. Switched
  to justifyContent: space-between + paddingHorizontal:20 so each
  tab hugs its label and the three labels spread to the edges with
  full horizontal breathing room.
- Post detail screen (/feed/[id]) and hashtag feed (/feed/tag/[tag])
  were missing the safe-area top inset — their headers butted right
  against the status bar / notch. Added useSafeAreaInsets().top as
  paddingTop on the outer View, matching the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 20:20:18 +03:00
parent 93040a0684
commit 51bc0a1850
4 changed files with 86 additions and 71 deletions

View File

@@ -21,6 +21,7 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
@@ -32,6 +33,7 @@ import {
} from '@/lib/feed'; } from '@/lib/feed';
export default function PostDetailScreen() { export default function PostDetailScreen() {
const insets = useSafeAreaInsets();
const { id: postID } = useLocalSearchParams<{ id: string }>(); const { id: postID } = useLocalSearchParams<{ id: string }>();
const keyFile = useStore(s => s.keyFile); const keyFile = useStore(s => s.keyFile);
@@ -73,7 +75,7 @@ export default function PostDetailScreen() {
}, []); }, []);
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000' }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}

View File

@@ -158,17 +158,18 @@ export default function FeedScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" /> <TabHeader title="Лента" />
{/* Tab strip — больше воздуха между табами. Горизонтальный padding {/* Tab strip — три таба, равномерно распределены по ширине
на контейнере + gap между Pressable'ами делает табы "breathable", (justifyContent: space-between). Каждый Pressable hug'ает
индикатор активной вкладки — тонкая полоска только под текстом, свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к
шириной примерно с лейбл. */} своему лейблу, что даёт воздух между ними. Индикатор активной
вкладки — тонкая полоска под лейблом. */}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 12, justifyContent: 'space-between',
gap: 10, paddingHorizontal: 20,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#141414', borderBottomColor: '#1f1f1f',
}} }}
> >
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => ( {(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
@@ -176,33 +177,31 @@ export default function FeedScreen() {
key={key} key={key}
onPress={() => setTab(key)} onPress={() => setTab(key)}
style={({ pressed }) => ({ style={({ pressed }) => ({
flex: 1,
alignItems: 'center', alignItems: 'center',
paddingVertical: 16, paddingVertical: 16,
backgroundColor: pressed ? '#0a0a0a' : 'transparent', paddingHorizontal: 6,
opacity: pressed ? 0.6 : 1,
})} })}
> >
<Text <Text
style={{ style={{
color: tab === key ? '#ffffff' : '#6a6a6a', color: tab === key ? '#ffffff' : '#6a6a6a',
fontWeight: tab === key ? '700' : '500', fontWeight: tab === key ? '700' : '500',
fontSize: 14, fontSize: 15,
letterSpacing: -0.1, letterSpacing: -0.1,
}} }}
> >
{TAB_LABELS[key]} {TAB_LABELS[key]}
</Text> </Text>
{tab === key && ( <View
<View style={{
style={{ marginTop: 10,
marginTop: 10, width: tab === key ? 28 : 0,
width: 32, height: 3,
height: 3, borderRadius: 1.5,
borderRadius: 1.5, backgroundColor: '#1d9bf0',
backgroundColor: '#1d9bf0', }}
}} />
/>
)}
</Pressable> </Pressable>
))} ))}
</View> </View>

View File

@@ -10,6 +10,7 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton'; import { IconButton } from '@/components/IconButton';
@@ -18,6 +19,7 @@ import { useStore } from '@/lib/store';
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed'; import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
export default function HashtagScreen() { export default function HashtagScreen() {
const insets = useSafeAreaInsets();
const { tag: rawTag } = useLocalSearchParams<{ tag: string }>(); const { tag: rawTag } = useLocalSearchParams<{ tag: string }>();
const tag = (rawTag ?? '').replace(/^#/, '').toLowerCase(); const tag = (rawTag ?? '').replace(/^#/, '').toLowerCase();
const keyFile = useStore(s => s.keyFile); const keyFile = useStore(s => s.keyFile);
@@ -75,7 +77,7 @@ export default function HashtagScreen() {
}, [keyFile]); }, [keyFile]);
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000' }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}

View File

@@ -173,11 +173,12 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
onLongPress={onLongPress} onLongPress={onLongPress}
style={({ pressed }) => ({ style={({ pressed }) => ({
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 14, paddingHorizontal: 16,
paddingVertical: compact ? 10 : 12, paddingTop: compact ? 12 : 16,
paddingBottom: compact ? 14 : 18,
backgroundColor: pressed ? '#080808' : 'transparent', backgroundColor: pressed ? '#080808' : 'transparent',
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#141414', borderBottomColor: '#222222',
})} })}
> >
{/* Avatar column */} {/* Avatar column */}
@@ -246,58 +247,69 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
</View> </View>
)} )}
{/* Action row */} {/* Action row — 4 evenly-spaced buttons (Twitter-style). Each is
wrapped in a flex: 1 container so even if one label is
wider than another, visual spacing between centres stays
balanced. */}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 8, justifyContent: 'space-between',
gap: 32, marginTop: 12,
paddingRight: 4,
}} }}
> >
<ActionButton <View style={{ flex: 1, alignItems: 'flex-start' }}>
icon="chatbubble-outline" <ActionButton
label={formatCount(0) /* replies count — not implemented yet */} icon="chatbubble-outline"
onPress={onOpenDetail} label={formatCount(0) /* replies count — not implemented yet */}
/> onPress={onOpenDetail}
<Pressable />
onPress={onToggleLike} </View>
disabled={busy} <View style={{ flex: 1, alignItems: 'flex-start' }}>
hitSlop={8} <Pressable
style={({ pressed }) => ({ onPress={onToggleLike}
flexDirection: 'row', alignItems: 'center', gap: 6, disabled={busy}
opacity: pressed ? 0.5 : 1, hitSlop={8}
})} style={({ pressed }) => ({
> flexDirection: 'row', alignItems: 'center', gap: 6,
<Animated.View style={{ transform: [{ scale: heartScale }] }}> opacity: pressed ? 0.5 : 1,
<Ionicons })}
name={localLiked ? 'heart' : 'heart-outline'}
size={16}
color={localLiked ? '#e0245e' : '#6a6a6a'}
/>
</Animated.View>
<Text
style={{
color: localLiked ? '#e0245e' : '#6a6a6a',
fontSize: 12,
fontWeight: localLiked ? '600' : '400',
}}
> >
{formatCount(localLikeCount)} <Animated.View style={{ transform: [{ scale: heartScale }] }}>
</Text> <Ionicons
</Pressable> name={localLiked ? 'heart' : 'heart-outline'}
<ActionButton size={16}
icon="eye-outline" color={localLiked ? '#e0245e' : '#6a6a6a'}
label={formatCount(post.views)} />
/> </Animated.View>
<View style={{ flex: 1 }} /> <Text
<ActionButton style={{
icon="share-outline" color: localLiked ? '#e0245e' : '#6a6a6a',
onPress={() => { fontSize: 12,
// Placeholder — copy postID to clipboard in a future PR. fontWeight: localLiked ? '600' : '400',
Alert.alert('Ссылка', `dchain://post/${post.post_id}`); }}
}} >
/> {formatCount(localLikeCount)}
</Text>
</Pressable>
</View>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<ActionButton
icon="eye-outline"
label={formatCount(post.views)}
/>
</View>
<View style={{ alignItems: 'flex-end' }}>
<ActionButton
icon="share-outline"
onPress={() => {
// Placeholder — copy postID to clipboard in a future PR.
Alert.alert('Ссылка', `dchain://post/${post.post_id}`);
}}
/>
</View>
</View> </View>
</View> </View>
</Pressable> </Pressable>