Files
vsecoder e62b72b5be fix(client): safeBack helper + prevent self-contact-request
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>
2026-04-18 23:45:19 +03:00

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)}`;
}