fix(feed): stack header + full-width content instead of avatar-sibling column

The previous layout put body text, attachments and action row inside
a column next to the avatar. Two recurring bugs came from that:

  1. The column's width = screen - 16 - 44 - 10 - 16 = screen - 86px.
     Long text or attachments computed against that narrower width,
     and on a few RN builds the measurement was off enough that text
     visibly ran past the card's right edge.
  2. The column visually looked weird: a photo rendered only 3/4 of
     the card width because the avatar stole 54px on the left.

Fix: make the card a vertical stack.

  ┌─────────────────────────────────────────┐
  │ [avatar] [name · time]          [menu]  │   ← HEADER row
  ├─────────────────────────────────────────┤
  │ body text, full card width              │   ← content column
  │ [attachment image, full card width]     │
  │ [action row, full card width]           │
  └─────────────────────────────────────────┘

Now body and media always occupy the full card width (paddingLeft:16
paddingRight:16 from the outer View), long lines wrap inside that,
and the earlier overflow-tricks / width-100% / paddingRight-4
band-aids aren't needed. Removed them.

Header row is unchanged structurally (avatar + name-row Pressable +
menu button) — just lifted into a dedicated View so the content
column below starts at the left card edge instead of alongside the
avatar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 21:35:07 +03:00
parent 9be1b60ef1
commit ab98f21aac

View File

@@ -190,86 +190,80 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
we're not relying on it here either. Tap handling lives on the we're not relying on it here either. Tap handling lives on the
content-column Pressable (covers ~90% of the card area) plus a content-column Pressable (covers ~90% of the card area) plus a
separate Pressable around the avatar. */} separate Pressable around the avatar. */}
{/* Card = vertical stack: [HEADER row with avatar+name+time] /
[FULL-WIDTH content column with body/image/actions]. Putting
content under the header (rather than in a column next to the
avatar) means body text and attachments occupy the full card
width — no risk of the text running next to the avatar and
clipping off the right edge. */}
<View <View
style={{ style={{
flexDirection: 'row',
paddingLeft: 16, paddingLeft: 16,
paddingRight: 16, paddingRight: 16,
paddingVertical: compact ? 10 : 12, paddingVertical: compact ? 10 : 12,
}} }}
> >
{/* Avatar — own tap target (opens author profile). Explicit width {/* ── HEADER ROW: [avatar] [name · time] [menu] ──────────────── */}
on the wrapper (width:44) so the flex-row sibling below computes <View style={{ flexDirection: 'row', alignItems: 'center' }}>
its remaining space correctly. */} <Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}>
<Pressable onPress={onOpenAuthor} hitSlop={4} style={{ width: 44 }}> <Avatar name={displayName} address={post.author} size={44} />
<Avatar name={displayName} address={post.author} size={44} /> </Pressable>
</Pressable>
{/* Content column. Pressable so the card body is tappable → {/* Name + time take all remaining horizontal space in the
detail; onLongPress routes to the context menu. overflow: header, with the name truncating (numberOfLines:1 +
'hidden' prevents unbreakable tokens from drawing past the flexShrink:1) and the "· <time>" tail pinned to stay on
right edge. */} the same line (flexShrink:0). */}
<Pressable
onPress={onOpenAuthor}
hitSlop={{ top: 4, bottom: 2 }}
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 10, minWidth: 0 }}
>
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
letterSpacing: -0.2,
flexShrink: 1,
}}
>
{displayName}
</Text>
<Text
numberOfLines={1}
style={{
color: '#6a6a6a',
fontSize: 13,
marginLeft: 6,
flexShrink: 0,
}}
>
· {formatRelativeTime(post.created_at)}
</Text>
</Pressable>
{mine && (
<Pressable onPress={onLongPress} hitSlop={8} style={{ marginLeft: 8 }}>
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
{/* ── CONTENT (body, attachment, actions) — full card width ──── */}
<Pressable <Pressable
onPress={onOpenDetail} onPress={onOpenDetail}
onLongPress={onLongPress} onLongPress={onLongPress}
style={({ pressed }) => ({ style={({ pressed }) => ({
flex: 1, marginTop: 8,
marginLeft: 10,
minWidth: 0,
overflow: 'hidden', overflow: 'hidden',
opacity: pressed ? 0.85 : 1, opacity: pressed ? 0.85 : 1,
})} })}
> >
{/* Header row — name + time on ONE line.
Two siblings: the author-link Pressable (flex:1, row, so it
expands; name inside gets numberOfLines:1 + flexShrink:1 so
it truncates instead of wrapping) and the "· <time>" tail
(flexShrink:0 — never truncates). Keeping the "·" inside the
time Text means they stay glued even if the inline layout
decides to break weirdly. */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Pressable
onPress={onOpenAuthor}
hitSlop={{ top: 4, bottom: 2 }}
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', minWidth: 0 }}
>
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
letterSpacing: -0.2,
flexShrink: 1,
}}
>
{displayName}
</Text>
<Text
numberOfLines={1}
style={{
color: '#6a6a6a',
fontSize: 13,
marginLeft: 6,
flexShrink: 0,
}}
>
· {formatRelativeTime(post.created_at)}
</Text>
</Pressable>
{mine && (
<Pressable onPress={onLongPress} hitSlop={8} style={{ marginLeft: 8 }}>
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
{/* Body text with hashtag highlighting. {/* Body text with hashtag highlighting. Full card width now
flexShrink:1 + explicit width:'100%' + paddingRight:4 keep (we moved it out of the avatar-sibling column) — no special
long lines inside the content column on every platform. On width tricks needed, normal wrapping just works. */}
Android a few RN versions have been known to let the inner
Text spans overflow the parent by 1-2 px without an explicit
width declaration — hence the belt-and-braces here. */}
{post.content.length > 0 && ( {post.content.length > 0 && (
<Text <Text
numberOfLines={bodyLines} numberOfLines={bodyLines}
@@ -278,10 +272,6 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
color: '#ffffff', color: '#ffffff',
fontSize: 15, fontSize: 15,
lineHeight: 20, lineHeight: 20,
marginTop: 2,
width: '100%',
flexShrink: 1,
paddingRight: 4,
}} }}
> >
{renderInline(post.content)} {renderInline(post.content)}