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>
This commit is contained in:
vsecoder
2026-04-18 23:45:19 +03:00
parent f7a849ddcb
commit e62b72b5be
10 changed files with 54 additions and 15 deletions

View File

@@ -26,7 +26,7 @@ import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId } from '@/lib/utils';
import { randomId, safeBack } from '@/lib/utils';
import type { Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
@@ -404,7 +404,7 @@ export default function ChatScreen() {
) : (
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={onOpenPeerProfile}

View File

@@ -37,6 +37,7 @@ import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
import { publishAndCommit, formatFee } from '@/lib/feed';
import { humanizeTxError, getBalance } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const MAX_CONTENT_LENGTH = 4000;
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
@@ -212,7 +213,7 @@ export default function ComposeScreen() {
borderBottomColor: '#141414',
}}
>
<Pressable onPress={() => router.back()} hitSlop={8}>
<Pressable onPress={() => safeBack()} hitSlop={8}>
<Ionicons name="close" size={26} color="#ffffff" />
</Pressable>
<View style={{ flex: 1 }} />

View File

@@ -31,6 +31,7 @@ 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();
@@ -71,14 +72,14 @@ export default function PostDetailScreen() {
const onDeleted = useCallback(() => {
// Go back to feed — the post is gone.
router.back();
safeBack();
}, []);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title="Post"
/>

View File

@@ -17,6 +17,7 @@ import { IconButton } from '@/components/IconButton';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function HashtagScreen() {
const insets = useSafeAreaInsets();
@@ -80,7 +81,7 @@ export default function HashtagScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={`#${tag}`}
/>

View File

@@ -18,7 +18,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
@@ -64,9 +64,20 @@ export default function NewContactScreen() {
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr;
}
// Block self-lookup — can't message yourself, and the on-chain
// CONTACT_REQUEST tx would go through but serve no purpose.
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setError("That's you. You can't send a contact request to yourself.");
return;
}
const identity = await getIdentity(address);
const resolvedAddr = identity?.pub_key ?? address;
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setError("That's you. You can't send a contact request to yourself.");
return;
}
setResolved({
address: identity?.pub_key ?? address,
address: resolvedAddr,
nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined,
});
@@ -79,6 +90,10 @@ export default function NewContactScreen() {
async function sendRequest() {
if (!resolved || !keyFile) return;
if (resolved.address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
Alert.alert('Can\'t message yourself', "This is your own address.");
return;
}
if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
return;
@@ -96,7 +111,7 @@ export default function NewContactScreen() {
Alert.alert(
'Request sent',
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => router.back() }],
[{ text: 'OK', onPress: () => safeBack() }],
);
} catch (e: any) {
setError(humanizeTxError(e));
@@ -114,7 +129,7 @@ export default function NewContactScreen() {
<Header
title="Search"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}

View File

@@ -28,6 +28,7 @@ import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { followUser, unfollowUser } from '@/lib/feed';
import { humanizeTxError } from '@/lib/api';
import { safeBack } from '@/lib/utils';
function shortAddr(a: string, n = 10): string {
if (!a) return '—';
@@ -87,7 +88,7 @@ export default function ProfileScreen() {
<Header
title="Profile"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>

View File

@@ -32,7 +32,7 @@ import {
humanizeTxError,
} from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
@@ -335,7 +335,7 @@ export default function SettingsScreen() {
<Header
title="Settings"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ paddingBottom: 120 }}