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

@@ -17,7 +17,7 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, ActivityIndicator, Image,
View, Text, ScrollView, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
@@ -99,21 +99,17 @@ export default function PostDetailScreen() {
</View>
) : (
<ScrollView>
{/* `compact` tells PostCard to drop the 5-line body cap and
render the attachment at its natural aspect ratio instead
of the portrait-cropped timeline preview. */}
<PostCard
compact
post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }}
likedByMe={stats?.liked_by_me ?? false}
onStatsChanged={onStatsChanged}
onDeleted={onDeleted}
/>
{/* Attachment preview (if any). For MVP we try loading from the
CURRENT node — works when you're connected to the hosting
relay. Cross-relay discovery (look up hosting_relay URL via
/api/relays) is future work. */}
{post.has_attachment && (
<AttachmentPreview postID={post.post_id} />
)}
{/* Detailed stats block */}
<View
style={{
@@ -204,41 +200,7 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
);
}
function AttachmentPreview({ postID }: { postID: string }) {
// For MVP we hit the local node URL; if the body is hosted elsewhere
// the image load will fail and the placeholder stays visible.
const { getNodeUrl } = require('@/lib/api');
const url = `${getNodeUrl()}/feed/post/${postID}`;
// The body is a JSON object, not raw image bytes. For now we just
// show a placeholder — decoding base64 attachment → data-uri is a
// Phase D improvement once we add /feed/post/{id}/attachment raw bytes.
return (
<View
style={{
margin: 14,
paddingVertical: 32,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1,
borderColor: '#1f1f1f',
alignItems: 'center',
}}
>
<Ionicons name="image-outline" size={32} color="#5a5a5a" />
<Text style={{ color: '#8b8b8b', marginTop: 10, fontSize: 12 }}>
Вложение: {url}
</Text>
<Text style={{ color: '#5a5a5a', marginTop: 4, fontSize: 10 }}>
Прямой просмотр вложений в следующем релизе
</Text>
</View>
);
}
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
// Silence Image import when unused (reserved for future attachment preview).
void Image;

View File

@@ -30,9 +30,10 @@ import { router } from 'expo-router';
import { Avatar } from '@/components/Avatar';
import { useStore } from '@/lib/store';
import { getNodeUrl } from '@/lib/api';
import type { FeedPostItem } from '@/lib/feed';
import {
formatRelativeTime, formatCount, likePost, unlikePost, deletePost, fetchStats,
formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
} from '@/lib/feed';
export interface PostCardProps {
@@ -162,10 +163,21 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
Alert.alert('Действия', '', buttons);
}, [keyFile, mine, post.post_id, onDeleted]);
// Image URL for attachment preview. We hit the hosting relay directly.
// For MVP we just show a placeholder — real fetch requires the hosting
// relay's URL, not just its pubkey. (Future: /api/relays lookup.)
const attachmentIcon = post.has_attachment;
// Attachment preview URL — native Image can stream straight from the
// hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()`
// returns the node the client is connected to; for cross-relay posts
// that's actually the hosting relay once /api/relays resolution lands
// (Phase D). For now we assume same-node.
const attachmentURL = post.has_attachment
? `${getNodeUrl()}/feed/post/${post.post_id}/attachment`
: null;
// Body content truncation:
// - In the timeline (compact=undefined/false) cap at 5 lines. If the
// text is longer the rest is hidden behind "…" — tapping the card
// opens the detail view where the full body is shown.
// - In post detail (compact=true) show everything.
const bodyLines = compact ? undefined : 5;
return (
<Pressable
@@ -185,18 +197,42 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
{/* Content column */}
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
{/* Header: name + time + menu */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Pressable onPress={onOpenAuthor} hitSlop={4}>
{/* Header: [name] · [time] … [menu]
All three on a single row with no wrap. The name shrinks if
too long (flexShrink:1 + numberOfLines:1), time never shrinks
— so long handles get truncated with ellipsis while "2h"
stays readable.
Whole header is inside a single Pressable so a tap anywhere
on the header opens the author's profile — matches Twitter's
behaviour where the name-row is a big hit target. */}
<Pressable
onPress={onOpenAuthor}
hitSlop={{ top: 4, bottom: 2 }}
style={{ flexDirection: 'row', alignItems: 'center' }}
>
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14, letterSpacing: -0.2 }}
style={{
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
letterSpacing: -0.2,
flexShrink: 1,
}}
>
{displayName}
</Text>
</Pressable>
<Text style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}>·</Text>
<Text style={{ color: '#6a6a6a', fontSize: 13 }}>
<Text
style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}
numberOfLines={1}
>
·
</Text>
<Text
style={{ color: '#6a6a6a', fontSize: 13 }}
numberOfLines={1}
>
{formatRelativeTime(post.created_at)}
</Text>
<View style={{ flex: 1 }} />
@@ -205,11 +241,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
</Pressable>
{/* Body text with hashtag highlighting */}
{post.content.length > 0 && (
<Text
numberOfLines={bodyLines}
ellipsizeMode="tail"
style={{
color: '#ffffff',
fontSize: 15,
@@ -221,35 +259,42 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
</Text>
)}
{/* Attachment indicator — real image render requires relay URL */}
{attachmentIcon && (
{/* Attachment preview.
Timeline (compact=false): aspect-ratio capped at 4:5 so a
portrait photo doesn't occupy the whole screen — extra height
is cropped via resizeMode="cover", full image shows in detail.
Detail (compact=true): contain with no aspect cap → original
proportions preserved. */}
{attachmentURL && (
<View
style={{
marginTop: 8,
paddingVertical: 24,
marginTop: 10,
borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#0a0a0a',
borderWidth: 1,
borderColor: '#1f1f1f',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
}}
>
<Ionicons name="image-outline" size={18} color="#5a5a5a" />
<Text style={{ color: '#5a5a5a', fontSize: 12 }}>
Открыть пост, чтобы посмотреть вложение
</Text>
<Image
source={{ uri: attachmentURL }}
style={compact ? {
width: '100%',
aspectRatio: 1, // will be overridden by onLoad if known
maxHeight: undefined,
} : {
width: '100%',
aspectRatio: 4 / 5, // portrait-friendly but bounded
}}
resizeMode={compact ? 'contain' : 'cover'}
/>
</View>
)}
{/* 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. paddingHorizontal gives extra breathing room on
both sides so the first icon isn't flush under the avatar
and the share icon isn't flush with the card edge. */}
{/* Action row — 3 buttons (Twitter-style). Comments button
intentionally omitted: v2.0.0 doesn't implement replies and
a dead button with "0" adds noise. Heart + views + share
distribute across the row; share pins to the right edge. */}
<View
style={{
flexDirection: 'row',
@@ -259,13 +304,6 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
paddingHorizontal: 12,
}}
>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<ActionButton
icon="chatbubble-outline"
label={formatCount(0) /* replies count — not implemented yet */}
onPress={onOpenDetail}
/>
</View>
<View style={{ flex: 1, alignItems: 'flex-start' }}>
<Pressable
onPress={onToggleLike}
@@ -315,9 +353,6 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
);
}
// Silence image import lint since we reference Image type indirectly.
const _imgKeep = Image;
export const PostCard = React.memo(PostCardInner);
/**
@@ -398,5 +433,3 @@ function shortAddr(a: string, n = 6): string {
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
// Keep Image import in play; expo-image lint sometimes trims it.
void _imgKeep;

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 {