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 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;
|
|
||||||
|
|||||||
@@ -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
|
||||||
<Text
|
— so long handles get truncated with ellipsis while "2h"
|
||||||
numberOfLines={1}
|
stays readable.
|
||||||
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14, letterSpacing: -0.2 }}
|
|
||||||
>
|
Whole header is inside a single Pressable so a tap anywhere
|
||||||
{displayName}
|
on the header opens the author's profile — matches Twitter's
|
||||||
</Text>
|
behaviour where the name-row is a big hit target. */}
|
||||||
</Pressable>
|
<Pressable
|
||||||
<Text style={{ color: '#6a6a6a', fontSize: 13, marginHorizontal: 4 }}>·</Text>
|
onPress={onOpenAuthor}
|
||||||
<Text style={{ color: '#6a6a6a', fontSize: 13 }}>
|
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)}
|
{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;
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user