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>
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
/**
|
||
* Post detail — full view of one post with stats, thread context, and a
|
||
* lazy-rendered image attachment.
|
||
*
|
||
* Why a dedicated screen?
|
||
* - PostCard in the timeline intentionally doesn't render attachments
|
||
* (would explode initial render time with N images).
|
||
* - Per-post stats (views, likes, liked_by_me) want a fresh refresh
|
||
* on open; timeline batches but not at the per-second cadence a
|
||
* reader expects when they just tapped in.
|
||
*
|
||
* Layout:
|
||
* [← back · Пост]
|
||
* [PostCard (full — with attachment)]
|
||
* [stats bar: views · likes · fee]
|
||
* [— reply affordance below (future)]
|
||
*/
|
||
import React, { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
View, Text, ScrollView, ActivityIndicator,
|
||
} from 'react-native';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { router, useLocalSearchParams } from 'expo-router';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
import { Header } from '@/components/Header';
|
||
import { IconButton } from '@/components/IconButton';
|
||
import { PostCard } from '@/components/feed/PostCard';
|
||
import { useStore } from '@/lib/store';
|
||
import {
|
||
fetchPost, fetchStats, bumpView, formatCount, formatFee,
|
||
type FeedPostItem, type PostStats,
|
||
} from '@/lib/feed';
|
||
|
||
export default function PostDetailScreen() {
|
||
const insets = useSafeAreaInsets();
|
||
const { id: postID } = useLocalSearchParams<{ id: string }>();
|
||
const keyFile = useStore(s => s.keyFile);
|
||
|
||
const [post, setPost] = useState<FeedPostItem | null>(null);
|
||
const [stats, setStats] = useState<PostStats | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!postID) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [p, s] = await Promise.all([
|
||
fetchPost(postID),
|
||
fetchStats(postID, keyFile?.pub_key),
|
||
]);
|
||
setPost(p);
|
||
setStats(s);
|
||
if (p) bumpView(postID); // fire-and-forget
|
||
} catch (e: any) {
|
||
setError(String(e?.message ?? e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [postID, keyFile]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const onStatsChanged = useCallback(async () => {
|
||
if (!postID) return;
|
||
const s = await fetchStats(postID, keyFile?.pub_key);
|
||
if (s) setStats(s);
|
||
}, [postID, keyFile]);
|
||
|
||
const onDeleted = useCallback(() => {
|
||
// Go back to feed — the post is gone.
|
||
router.back();
|
||
}, []);
|
||
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||
<Header
|
||
divider
|
||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||
title="Пост"
|
||
/>
|
||
|
||
{loading ? (
|
||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||
<ActivityIndicator color="#1d9bf0" />
|
||
</View>
|
||
) : error ? (
|
||
<View style={{ padding: 24 }}>
|
||
<Text style={{ color: '#f4212e' }}>{error}</Text>
|
||
</View>
|
||
) : !post ? (
|
||
<View style={{ padding: 24, alignItems: 'center' }}>
|
||
<Ionicons name="trash-outline" size={32} color="#6a6a6a" />
|
||
<Text style={{ color: '#8b8b8b', marginTop: 10 }}>
|
||
Пост удалён или больше недоступен
|
||
</Text>
|
||
</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}
|
||
/>
|
||
|
||
{/* Detailed stats block */}
|
||
<View
|
||
style={{
|
||
marginHorizontal: 14,
|
||
marginTop: 12,
|
||
paddingVertical: 14,
|
||
paddingHorizontal: 14,
|
||
borderRadius: 14,
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1,
|
||
borderColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
<Text style={{
|
||
color: '#5a5a5a',
|
||
fontSize: 11,
|
||
fontWeight: '700',
|
||
letterSpacing: 1.2,
|
||
textTransform: 'uppercase',
|
||
marginBottom: 10,
|
||
}}>
|
||
Информация о посте
|
||
</Text>
|
||
|
||
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} />
|
||
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} />
|
||
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
|
||
<DetailRow
|
||
label="Стоимость публикации"
|
||
value={formatFee(1000 + post.size)}
|
||
/>
|
||
<DetailRow
|
||
label="Хостинг"
|
||
value={shortAddr(post.hosting_relay)}
|
||
mono
|
||
/>
|
||
|
||
{post.hashtags && post.hashtags.length > 0 && (
|
||
<>
|
||
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
|
||
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
|
||
Хештеги
|
||
</Text>
|
||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||
{post.hashtags.map(tag => (
|
||
<Text
|
||
key={tag}
|
||
onPress={() => router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)}
|
||
style={{
|
||
color: '#1d9bf0',
|
||
fontSize: 13,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 3,
|
||
backgroundColor: '#081a2a',
|
||
borderRadius: 999,
|
||
}}
|
||
>
|
||
#{tag}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
</>
|
||
)}
|
||
</View>
|
||
|
||
<View style={{ height: 80 }} />
|
||
</ScrollView>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||
return (
|
||
<View style={{ flexDirection: 'row', paddingVertical: 3 }}>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||
<Text
|
||
style={{
|
||
color: '#ffffff',
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
fontFamily: mono ? 'monospace' : undefined,
|
||
}}
|
||
>
|
||
{value}
|
||
</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)}`;
|
||
}
|