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:
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14, letterSpacing: -0.2 }}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}>·</Text>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 13 }}>
|
||||
{/* 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,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user