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>
Same Pressable-dynamic-style-function bug that bit the FAB: on some
RN versions the style function is re-evaluated mid-render in a way
that drops properties — here we lost paddingLeft:16 and sometimes
flexDirection:'row' on the outer PostCard Pressable, which is why
the avatar ended up flush against the left edge and the header/body
sometimes stacked into a column instead of row.
Fix: move layout to a plain <View> wrapper (static styles, can never
be dropped). Tap handling stays on two Pressables:
- the avatar wrapper (opens author profile),
- the content column (opens post detail + long-press for menu).
The card area visually covered by these two Pressables is ~100% —
tap anywhere on a post still navigates to detail, tap on the avatar
still goes to the author. No interaction regression.
Also paired opacity press-state on the content-column Pressable so
the feedback visual matches what the old bg-colour press gave.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Three related layout polish issues.
1. Avatar + name + time splitting to two lines
Fixed by collapsing the "·" separator + time into a single Text
component (was three sibling Texts). RN occasionally lays out
three-Text-sibling rows oddly when the name is long because each
Text measures independently. With the separator glued to the time,
there's one indivisible chip for "· 2h" that flexShrink:0 keeps on
the row; the name takes the rest and truncates with "…" if needed.
Also wrapped the name + time in a flex:1 Pressable (author tap
target) so the row width is authoritative.
2. Avatar flush against the left edge
The outer Pressable's style-function paddingLeft was being applied
but the visual offset felt wrong because avatar has no explicit
width. Added width:44 to the avatar's own Pressable wrapper so the
flex-row layout reserves exactly the expected space and the 16-px
paddingLeft on the outer Pressable is visible around the avatar's
left edge.
3. Like / view count text visually below icon's centre
RN Text includes extra top padding (Android) and baseline-anchors
(iOS) that push the number glyph a pixel or two below the icon
centre in a flex-row with alignItems:'center'. Added explicit
lineHeight:16 (matching icon size) + includeFontPadding:false +
textAlignVertical:'center' on both the heart button's Text and the
shared ActionButton Text → numbers now sit on the same optical
baseline as their icons on both platforms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The body Text inherited the content column's width only implicitly
via flex:1 on the parent, which on some Android RN builds was
computed one or two pixels wider than the true column width — enough
to make a long line visually escape the right side of the card.
Belt-and-braces fix:
- content column gets overflow:'hidden' so anything that escapes
the computed width gets clipped instead of drawn outside.
- body Text explicitly sets width:'100%' + flexShrink:1 +
paddingRight:4 so the wrapping algorithm always knows the
authoritative maximum and leaves a 4-px visual buffer from the
column edge.
Together these guarantee long single-line posts wrap correctly on
both iOS and Android, and short-but-wide tokens (URLs, long
hashtags) ellipsize instead of poking out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server
node/api_feed.go: new GET /feed/post/{id}/attachment route. Returns
raw attachment bytes with the correct Content-Type so React Native's
<Image source={uri}> can stream them directly without the client
fetching + decoding base64 from the main /feed/post/{id} JSON (would
blow up memory on a 40-post timeline). Respects on-chain soft-delete
(410 when tombstoned). Cache-Control: public, max-age=3600, immutable
— attachments are content-addressed so aggressive caching is safe.
PostCard — rewritten header row
- Avatar + name + time collapsed into a single Pressable row with
flexDirection:'row'. Name gets flexShrink:1 + numberOfLines:1 so
long handles truncate with "…" mid-row instead of pushing the time
onto a second line. Time and separator dot both numberOfLines:1
with no flex — they never shrink, so "2h" stays readable.
- Whole header is one tap target → navigates to the author's profile.
PostCard — body truncation
- Timeline view (compact=false): numberOfLines={5} + ellipsizeMode:
'tail'. Long posts collapse to 5 lines with "…"; tapping the card
opens the detail view where the full body is shown.
- Detail view (compact=true): no line cap — full text, then full-
size attachment below.
PostCard — real image previews
- <Image source={{ uri: `${node}/feed/post/${id}/attachment` }}>
(feed layout).
- Timeline: aspectRatio: 4/5 + resizeMode:'cover' — portrait photos
get cropped so one tall image can't eat the whole feed.
- Detail: aspectRatio: 1 + resizeMode:'contain' so the full image
fits in its original proportions (crop-free).
PostCard — comments button removed
v2.0.0 doesn't implement replies; a dead button with label "0" was
noise. Action row now has 3 cells: heart (with live like count),
eye (views), share (pinned right). Spacing stays balanced because
each of the first two cells is still flex:1.
Post detail screen
- Passes compact prop so the PostCard above renders in full-body /
full-attachment mode.
- Dropped the old AttachmentPreview placeholder — PostCard now
handles images in both modes.
Tests
- go test ./... — all 7 packages green (blockchain / consensus /
identity / media / node / relay / vm).
- tsc --noEmit on client-app — 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Post action row (chat / ❤ / eye / share) had only a small paddingRight
and no left padding. First icon sat flush under the avatar and share
iron against the card edge. Replaced with paddingHorizontal: 12 so
both sides get equal breathing room; each of the four cells still
flex:1 so the icons distribute evenly.
- FAB kept appearing at the LEFT edge instead of the right on user's
device despite position:absolute + right:12. Pressable's dynamic-
function style can drop absolute-positioning fields between renders
on some RN versions. Wrapping the Pressable in a plain absolute-
positioned View fixes this: positioning lives on the View (never
re-evaluated mid-render), the Pressable inside only declares size
and visuals. pointerEvents="box-none" on the wrapper keeps taps
outside the button passing through to the feed list below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PostSeparator was a bare 1px line flush against both neighbouring
cards, so the seam looked like card contact instead of a real
break. Wrapped it in a 12px vertical padding — now there's 12px
blank above the line, 1px line, 12px blank below. Trimmed the
card's own paddingVertical back down since the separator now owns
the inter-post breathing room.
- FAB was lifted to bottom: max(insets.bottom, 8) + 70 in the previous
commit which put it far too high (a leftover from the original
experiment with the absoluteFill wrapper). User wants 12px above
the NavBar and 12px from the right edge. The Feed screen's
container ends at the NavBar top (enforced by (app)/_layout.tsx's
outer <View flex:1> + NavBar sibling), so a simple `right: 12,
bottom: 12` on position:absolute lands the button exactly there on
every device. Removed the now-unnecessary absoluteFill wrapper too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
- 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>
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).
Feed screens
app/(app)/feed.tsx — Feed tab
- Three-way tab strip: Подписки / Для вас / В тренде backed by
/feed/timeline, /feed/foryou, /feed/trending respectively
- Default landing tab is "Для вас" — surfaces discovery without
requiring the user to follow anyone first
- FlatList with pull-to-refresh + viewability-driven view counter
bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
- Floating blue compose button → /compose
- Per-post liked_by_me fetched in batches of 6 after list load
app/(app)/compose.tsx — post composer modal
- Fullscreen, Twitter-like header (✕ left, Опубликовать right)
- Auto-focused multiline TextInput, 4000 char cap
- Hashtag preview chips that auto-update as you type
- expo-image-picker + expo-image-manipulator pipeline: resize to
1080px max-dim, JPEG Q=50 (client-side first-pass compression
before the mandatory server-side scrub)
- Live fee estimate + balance guard with a confirmation modal
("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
- Exif: false passed to ImagePicker as an extra privacy layer
app/(app)/feed/[id].tsx — post detail
- Full PostCard rendering + detailed info panel (views, likes,
size, fee, hosting relay, hashtags as tappable chips)
- Triggers bumpView on mount
- 410 (on-chain soft-delete) routes back to the feed
app/(app)/feed/tag/[tag].tsx — hashtag feed
app/(app)/profile/[address].tsx — rebuilt
- Twitter-ish profile: avatar, name, address short-form, post count
- Posts | Инфо tab strip
- Follow / Unfollow button for non-self profiles (optimistic UI)
- Edit button on self profile → settings
- Secondary actions (chat, copy address) when viewing a known contact
Supporting library
lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
- publishPost (POST /feed/publish, signed)
- publishAndCommit (publish → on-chain CREATE_POST)
- fetchPost / fetchStats / bumpView
- fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
fetchHashtag
- buildCreatePostTx / buildDeletePostTx
- buildFollowTx / buildUnfollowTx
- buildLikePostTx / buildUnlikePostTx
- likePost / unlikePost / followUser / unfollowUser / deletePost
(high-level helpers that bundle build + submitTx)
- formatFee, formatRelativeTime, formatCount — Twitter-like display
helpers
components/feed/PostCard.tsx — core card component
- Memoised for performance (N-row re-render on every like elsewhere
would cost a lot otherwise)
- Optimistic like toggle with heart-bounce spring animation
- Hashtag highlighting in body text (tappable → hashtag feed)
- Long-press context menu (Delete, owner-only)
- Views / likes / share-link / reply icons in footer row
Navigation cleanup
- NavBar: removed the SOON pill on the Feed tab (it's shipped now)
- (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
- AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
sub-routes so back-swipe-right closes them
Channel removal (client side)
- lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
'channel' flag removed. `kind` field kept for backward compat with
existing AsyncStorage records.
- lib/devSeed.ts: dropped the 5 channel seed contacts.
- components/ChatTile.tsx: removed channel kindIcon branch.
Dependencies
- expo-image-manipulator added for client-side image compression.
- expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
that API to the legacy sub-path; the new streaming API isn't yet
stable).
Type check
- npx tsc --noEmit — clean, 0 errors.
Next (not in this commit)
- Direct attachment-bytes endpoint on the server so post-detail can
actually render the image (currently shows placeholder with URL)
- Cross-relay body fetch via /api/relays + hosting_relay pubkey
- Mentions (@username) with notifications
- Full-text search
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>