- PostCard rows got cramped paddings and a near-invisible divider. Increased paddingTop 12→16, paddingBottom 12→18, paddingHorizontal 14→16; divider colour #141414→#222222 so the seam between posts is legible on OLED blacks. - Action row (chat / ❤ / view / share) used a fixed gap:32 + spacer. Reworked to four flex:1 cells with justifyContent: space-between, so the first three icons distribute evenly across the row and share pins to the right edge. Matches Twitter's layout where each action occupies a quarter of the row regardless of label width. - Feed tab strip (Подписки / Для вас / В тренде) used flex:1 + gap:10 which bunched the three labels together visually. Switched to justifyContent: space-between + paddingHorizontal:20 so each tab hugs its label and the three labels spread to the edges with full horizontal breathing room. - Post detail screen (/feed/[id]) and hashtag feed (/feed/tag/[tag]) were missing the safe-area top inset — their headers butted right against the status bar / notch. Added useSafeAreaInsets().top as paddingTop on the outer View, matching the rest of the app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
8.2 KiB
TypeScript
245 lines
8.2 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, Image,
|
||
} 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>
|
||
<PostCard
|
||
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={{
|
||
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 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;
|