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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user