feat(feed): image previews + inline header + 5-line truncation + drop comments

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>
This commit is contained in:
vsecoder
2026-04-18 20:38:15 +03:00
parent 7bfd8c7dea
commit c5ca7a0612
3 changed files with 134 additions and 91 deletions

View File

@@ -294,12 +294,60 @@ func feedPostRouter(cfg FeedConfig) http.HandlerFunc {
feedPostStats(cfg)(w, r, postID)
case "view":
feedPostView(cfg)(w, r, postID)
case "attachment":
feedPostAttachment(cfg)(w, r, postID)
default:
jsonErr(w, fmt.Errorf("unknown sub-route %q", parts[1]), 404)
}
}
}
// feedPostAttachment handles GET /feed/post/{id}/attachment — returns the
// raw attachment bytes with the correct Content-Type so clients can use
// the URL directly as an <Image source={uri: ...}>.
//
// Why a dedicated endpoint? The /feed/post/{id} response wraps the body
// as base64 inside JSON; fetching that + decoding for N posts in a feed
// list would blow up memory. Native image loaders stream bytes straight
// to the GPU — this route lets them do that without intermediate JSON.
//
// Respects on-chain soft-delete: returns 410 when the post is tombstoned.
func feedPostAttachment(cfg FeedConfig) postHandler {
return func(w http.ResponseWriter, r *http.Request, postID string) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if cfg.GetPost != nil {
if rec, _ := cfg.GetPost(postID); rec != nil && rec.Deleted {
jsonErr(w, fmt.Errorf("post %s deleted", postID), 410)
return
}
}
post, err := cfg.Mailbox.Get(postID)
if err != nil {
jsonErr(w, err, 500)
return
}
if post == nil || len(post.Attachment) == 0 {
jsonErr(w, fmt.Errorf("no attachment for post %s", postID), 404)
return
}
mime := post.AttachmentMIME
if mime == "" {
mime = "application/octet-stream"
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(post.Attachment)))
// Cache for 1 hour — attachments are immutable (tied to content_hash),
// so aggressive client-side caching is safe and saves bandwidth.
w.Header().Set("Cache-Control", "public, max-age=3600, immutable")
w.Header().Set("ETag", `"`+postID+`"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(post.Attachment)
}
}
type postHandler func(w http.ResponseWriter, r *http.Request, postID string)
func feedGetPost(cfg FeedConfig) postHandler {