Mixed-language UI was confusing — onboarding said "Why DChain / How it
works / Your keys" in English headings but feature descriptions and
CTAs were in Russian; compose's confirm dialog was Russian; feed tabs
were Russian; error messages in humanizeTxError were Russian.
Everything user-facing is now English.
Files touched (only string literals, not comments):
app/index.tsx onboarding slides + CTA buttons
app/(app)/compose.tsx composer alerts, header button, placeholder,
attachment-size hint
app/(app)/feed/index.tsx tab labels (Following/For you/Trending),
empty-state hints, retry button
app/(app)/feed/[id].tsx post detail header + stats rows (Views,
Likes, Size, Paid to publish, Hosted on,
Hashtags)
app/(app)/feed/tag/[tag].tsx empty-state copy
app/(app)/profile/[address].tsx Profile header, Follow/Following,
Edit, Open chat, Address, Copied, Encryption,
Added, Members, unknown-contact hint
app/(app)/new-contact.tsx Search title, placeholder, Search button,
empty-state hint, E2E-ready indicator,
Intro label + placeholder, fee-tier labels
(Min / Standard / Priority), Send request,
Insufficient-balance alert, Request-sent
alert
app/(app)/requests.tsx Notifications title, empty-state, Accept /
Decline buttons, decline-confirm alert,
"wants to add you" line
components/SearchBar.tsx default placeholder
components/feed/PostCard.tsx long-press menu (Delete post, confirm,
Actions / Cancel)
components/feed/ShareSheet.tsx sheet title, contact-search placeholder,
empty state, Select contacts / Send button,
plural helper rewritten for English
components/chat/PostRefCard.tsx "POST" ribbon, "photo" indicator
lib/api.ts humanizeTxError (rate-limit, clock skew,
bad signature, 400/5xx/network-error
messages)
lib/dates.ts dateBucket now returns Today/Yesterday/
"Jun 17, 2025"; month array switched to
English short forms
Code comments left in Russian intentionally — they're developer
context, not user-facing. This commit is purely display-string.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
6.7 KiB
TypeScript
207 lines
6.7 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="Post"
|
|
/>
|
|
|
|
{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 }}>
|
|
Post deleted or no longer available
|
|
</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,
|
|
}}>
|
|
Post details
|
|
</Text>
|
|
|
|
<DetailRow label="Views" value={formatCount(stats?.views ?? post.views)} />
|
|
<DetailRow label="Likes" value={formatCount(stats?.likes ?? post.likes)} />
|
|
<DetailRow label="Size" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
|
|
<DetailRow
|
|
label="Paid to publish"
|
|
value={formatFee(1000 + post.size)}
|
|
/>
|
|
<DetailRow
|
|
label="Hosted on"
|
|
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 }}>
|
|
Hashtags
|
|
</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)}`;
|
|
}
|