1. GO_BACK warning & stuck screens
When a deep link or direct push put the user on /feed/[id],
/profile/[address], /compose, or /settings without any prior stack
entry, tapping the header chevron emitted:
"ERROR The action 'GO_BACK' was not handled by any navigator"
and did nothing — user was stuck.
New helper lib/utils.safeBack(fallback = '/(app)/chats') wraps
router.canGoBack() — when there's history it pops; otherwise it
replace-navigates to a sensible fallback (chats list by default,
'/' for auth screens so we land back at the onboarding).
Applied to every header chevron and back-from-detail flow:
app/(app)/chats/[id], app/(app)/compose, app/(app)/feed/[id]
(header + onDeleted), app/(app)/feed/tag/[tag],
app/(app)/profile/[address], app/(app)/new-contact (header + OK
button on request-sent alert), app/(app)/settings,
app/(auth)/create, app/(auth)/import.
2. Prevent self-contact-request
new-contact.tsx now compares the resolved address against
keyFile.pub_key at two points:
- right after resolveUsername + getIdentity in search() — before
the profile card even renders, so the user doesn't see the
"Send request" CTA for themselves.
- inside sendRequest() as a belt-and-braces guard in case the
check was somehow bypassed.
The search path shows an inline error ("That's you. You can't
send a contact request to yourself."); sendRequest falls back to
an Alert with the same meaning. Both compare case-insensitively
against the pubkey hex so mixed-case pastes work.
Technically the server would still accept a self-request (the
chain stores it under contact_in:<self>:<self>), but it's a dead-
end UX-wise — the user can't chat with themselves — so the client
blocks it preemptively instead of letting users pay the fee for
nothing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
6.8 KiB
TypeScript
208 lines
6.8 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';
|
|
import { safeBack } from '@/lib/utils';
|
|
|
|
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.
|
|
safeBack();
|
|
}, []);
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
|
<Header
|
|
divider
|
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
|
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)}`;
|
|
}
|