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 React, { useCallback, useEffect, useState } from 'react';
import { import {
View, Text, ScrollView, ActivityIndicator, Image, View, Text, ScrollView, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
@@ -99,21 +99,17 @@ export default function PostDetailScreen() {
</View> </View>
) : ( ) : (
<ScrollView> <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 <PostCard
compact
post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }} post={{ ...post, likes: stats?.likes ?? post.likes, views: stats?.views ?? post.views }}
likedByMe={stats?.liked_by_me ?? false} likedByMe={stats?.liked_by_me ?? false}
onStatsChanged={onStatsChanged} onStatsChanged={onStatsChanged}
onDeleted={onDeleted} 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 */} {/* Detailed stats block */}
<View <View
style={{ 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 { function shortAddr(a: string, n = 6): string {
if (!a) return '—'; if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`; 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 { Avatar } from '@/components/Avatar';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { getNodeUrl } from '@/lib/api';
import type { FeedPostItem } from '@/lib/feed'; import type { FeedPostItem } from '@/lib/feed';
import { import {
formatRelativeTime, formatCount, likePost, unlikePost, deletePost, fetchStats, formatRelativeTime, formatCount, likePost, unlikePost, deletePost,
} from '@/lib/feed'; } from '@/lib/feed';
export interface PostCardProps { export interface PostCardProps {
@@ -162,10 +163,21 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
Alert.alert('Действия', '', buttons); Alert.alert('Действия', '', buttons);
}, [keyFile, mine, post.post_id, onDeleted]); }, [keyFile, mine, post.post_id, onDeleted]);
// Image URL for attachment preview. We hit the hosting relay directly. // Attachment preview URL — native Image can stream straight from the
// For MVP we just show a placeholder — real fetch requires the hosting // hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()`
// relay's URL, not just its pubkey. (Future: /api/relays lookup.) // returns the node the client is connected to; for cross-relay posts
const attachmentIcon = post.has_attachment; // 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 ( return (
<Pressable <Pressable
@@ -185,18 +197,42 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
{/* Content column */} {/* Content column */}
<View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}> <View style={{ flex: 1, marginLeft: 10, minWidth: 0 }}>
{/* Header: name + time + menu */} {/* Header: [name] · [time] … [menu]
<View style={{ flexDirection: 'row', alignItems: 'center' }}> All three on a single row with no wrap. The name shrinks if
<Pressable onPress={onOpenAuthor} hitSlop={4}> 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 <Text
numberOfLines={1} 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} {displayName}
</Text> </Text>
</Pressable> <Text
<Text style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}>·</Text> style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}
<Text style={{ color: '#6a6a6a', fontSize: 13 }}> numberOfLines={1}
>
·
</Text>
<Text
style={{ color: '#6a6a6a', fontSize: 13 }}
numberOfLines={1}
>
{formatRelativeTime(post.created_at)} {formatRelativeTime(post.created_at)}
</Text> </Text>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
@@ -205,11 +241,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
<Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" /> <Ionicons name="ellipsis-horizontal" size={16} color="#6a6a6a" />
</Pressable> </Pressable>
)} )}
</View> </Pressable>
{/* Body text with hashtag highlighting */} {/* Body text with hashtag highlighting */}
{post.content.length > 0 && ( {post.content.length > 0 && (
<Text <Text
numberOfLines={bodyLines}
ellipsizeMode="tail"
style={{ style={{
color: '#ffffff', color: '#ffffff',
fontSize: 15, fontSize: 15,
@@ -221,35 +259,42 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
</Text> </Text>
)} )}
{/* Attachment indicator — real image render requires relay URL */} {/* Attachment preview.
{attachmentIcon && ( 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 <View
style={{ style={{
marginTop: 8, marginTop: 10,
paddingVertical: 24,
borderRadius: 14, borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#0a0a0a', backgroundColor: '#0a0a0a',
borderWidth: 1, borderWidth: 1,
borderColor: '#1f1f1f', borderColor: '#1f1f1f',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
}} }}
> >
<Ionicons name="image-outline" size={18} color="#5a5a5a" /> <Image
<Text style={{ color: '#5a5a5a', fontSize: 12 }}> source={{ uri: attachmentURL }}
Открыть пост, чтобы посмотреть вложение style={compact ? {
</Text> 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> </View>
)} )}
{/* Action row — 4 evenly-spaced buttons (Twitter-style). Each is {/* Action row — 3 buttons (Twitter-style). Comments button
wrapped in a flex: 1 container so even if one label is intentionally omitted: v2.0.0 doesn't implement replies and
wider than another, visual spacing between centres stays a dead button with "0" adds noise. Heart + views + share
balanced. paddingHorizontal gives extra breathing room on distribute across the row; share pins to the right edge. */}
both sides so the first icon isn't flush under the avatar
and the share icon isn't flush with the card edge. */}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
@@ -259,13 +304,6 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
paddingHorizontal: 12, 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' }}> <View style={{ flex: 1, alignItems: 'flex-start' }}>
<Pressable <Pressable
onPress={onToggleLike} 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); 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)}`; 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) feedPostStats(cfg)(w, r, postID)
case "view": case "view":
feedPostView(cfg)(w, r, postID) feedPostView(cfg)(w, r, postID)
case "attachment":
feedPostAttachment(cfg)(w, r, postID)
default: default:
jsonErr(w, fmt.Errorf("unknown sub-route %q", parts[1]), 404) 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) type postHandler func(w http.ResponseWriter, r *http.Request, postID string)
func feedGetPost(cfg FeedConfig) postHandler { func feedGetPost(cfg FeedConfig) postHandler {