fix(feed): visible post divider + reliable FAB positioning

- Post divider was on each PostCard's outer Pressable as borderBottom
  (#222), which was barely visible on OLED black and disappeared
  entirely in pressed state (the pressed bg ate the line). Moved the
  seam to a dedicated PostSeparator component (1px, #2a2a2a) wired as
  FlatList's ItemSeparatorComponent on both /feed (timeline / for-you
  / trending) and /feed/tag/[tag]. Also bumped inter-card vertical
  padding (14-16 top / 16-20 bottom) so cards have real breathing room
  even before the divider.
- FAB position was flaky: with <Stack> at the (app) level the overlay
  could end up positioned against the Stack's card view instead of the
  tab container, which made the button drift around and stick against
  unexpected edges. Wrapped it in an absoluteFill container with
  pointerEvents="box-none" — the wrapper owns positioning against the
  tab screen, the button inside just declares right: 14 / bottom: N.
  Bumped bottom offset to `max(insets.bottom, 8) + 70` so the FAB
  always clears the 5-icon NavBar with ~14px visual gap on every
  device. Shadow switched from blue-cast to standard dark for better
  depth perception on dark backgrounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 20:23:52 +03:00
parent 51bc0a1850
commit a248c540d5
3 changed files with 55 additions and 27 deletions

View File

@@ -21,7 +21,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { TabHeader } from '@/components/TabHeader';
import { PostCard } from '@/components/feed/PostCard';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import {
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
@@ -218,6 +218,7 @@ export default function FeedScreen() {
onDeleted={onDeleted}
/>
)}
ItemSeparatorComponent={PostSeparator}
refreshControl={
<RefreshControl
refreshing={refreshing}
@@ -250,29 +251,43 @@ export default function FeedScreen() {
contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined}
/>
{/* Floating compose button — pinned to the bottom-right corner
with 14px side inset. Vertical offset clears the 5-icon NavBar
(which lives below this view in the same layer) by sitting
~14px above its top edge. */}
<Pressable
onPress={() => router.push('/(app)/compose' as never)}
style={({ pressed }) => ({
{/* Floating compose button.
*
* Wrapped in a StyleSheet.absoluteFill container with pointerEvents
* "box-none" so only the FAB captures touches — taps anywhere else
* pass through to the FlatList below.
*
* Inside the wrapper, alignSelf: 'flex-end' pins to the right;
* bottom inset leaves ~14px clearance above the NavBar (≈56px tall
* + safe-area-bottom). Explicit `right: 14` is belt-and-braces
* for RTL / platform quirks where alignSelf alone might not pin. */}
<View
pointerEvents="box-none"
style={{
position: 'absolute',
right: 14,
bottom: Math.max(insets.bottom, 0) + 62,
width: 56, height: 56,
borderRadius: 28,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
shadowColor: '#1d9bf0',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 6,
})}
left: 0, right: 0, top: 0, bottom: 0,
}}
>
<Ionicons name="create-outline" size={24} color="#ffffff" />
</Pressable>
<Pressable
onPress={() => router.push('/(app)/compose' as never)}
style={({ pressed }) => ({
position: 'absolute',
right: 14,
bottom: Math.max(insets.bottom, 8) + 70,
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>
);
}

View File

@@ -14,7 +14,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { PostCard } from '@/components/feed/PostCard';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
@@ -94,6 +94,7 @@ export default function HashtagScreen() {
onStatsChanged={onStatsChanged}
/>
)}
ItemSeparatorComponent={PostSeparator}
refreshControl={
<RefreshControl
refreshing={refreshing}

View File

@@ -174,11 +174,9 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
style={({ pressed }) => ({
flexDirection: 'row',
paddingHorizontal: 16,
paddingTop: compact ? 12 : 16,
paddingBottom: compact ? 14 : 18,
paddingTop: compact ? 14 : 18,
paddingBottom: compact ? 16 : 20,
backgroundColor: pressed ? '#080808' : 'transparent',
borderBottomWidth: 1,
borderBottomColor: '#222222',
})}
>
{/* Avatar column */}
@@ -321,6 +319,20 @@ const _imgKeep = Image;
export const PostCard = React.memo(PostCardInner);
/**
* PostSeparator — visible divider line between post cards. Exported so
* every feed surface (timeline, author, hashtag, post detail) can pass
* it as ItemSeparatorComponent and get identical spacing / colour.
*
* The line colour (#2a2a2a) is the minimum grey that reads on OLED
* black under mobile-bright-mode gamma — go darker and the seam vanishes.
* Height 1 is one logical px (hairline on retina). No horizontal inset:
* Twitter runs the seam edge-to-edge and it looks cleaner than a gap.
*/
export function PostSeparator() {
return <View style={{ height: 1, backgroundColor: '#2a2a2a' }} />;
}
// ── Inline helpers ──────────────────────────────────────────────────────
/** ActionButton — small icon + optional label. */