Files
dchain/client-app/app/(app)/feed/[id].tsx
vsecoder 51bc0a1850 fix(feed): card spacing, action-row distribution, tab strip, detail inset
- 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>
2026-04-18 20:20:18 +03:00

245 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;