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:
@@ -26,7 +26,7 @@ import { encryptMessage } from '@/lib/crypto';
|
|||||||
import { sendEnvelope } from '@/lib/api';
|
import { sendEnvelope } from '@/lib/api';
|
||||||
import { getWSClient } from '@/lib/ws';
|
import { getWSClient } from '@/lib/ws';
|
||||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||||
import { randomId } from '@/lib/utils';
|
import { randomId, safeBack } from '@/lib/utils';
|
||||||
import type { Message } from '@/lib/types';
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
@@ -404,7 +404,7 @@ export default function ChatScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<Header
|
<Header
|
||||||
divider
|
divider
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
title={
|
title={
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onOpenPeerProfile}
|
onPress={onOpenPeerProfile}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { useStore } from '@/lib/store';
|
|||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { publishAndCommit, formatFee } from '@/lib/feed';
|
import { publishAndCommit, formatFee } from '@/lib/feed';
|
||||||
import { humanizeTxError, getBalance } from '@/lib/api';
|
import { humanizeTxError, getBalance } from '@/lib/api';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 4000;
|
const MAX_CONTENT_LENGTH = 4000;
|
||||||
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
|
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
|
||||||
@@ -212,7 +213,7 @@ export default function ComposeScreen() {
|
|||||||
borderBottomColor: '#141414',
|
borderBottomColor: '#141414',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={() => router.back()} hitSlop={8}>
|
<Pressable onPress={() => safeBack()} hitSlop={8}>
|
||||||
<Ionicons name="close" size={26} color="#ffffff" />
|
<Ionicons name="close" size={26} color="#ffffff" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
fetchPost, fetchStats, bumpView, formatCount, formatFee,
|
fetchPost, fetchStats, bumpView, formatCount, formatFee,
|
||||||
type FeedPostItem, type PostStats,
|
type FeedPostItem, type PostStats,
|
||||||
} from '@/lib/feed';
|
} from '@/lib/feed';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
export default function PostDetailScreen() {
|
export default function PostDetailScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -71,14 +72,14 @@ export default function PostDetailScreen() {
|
|||||||
|
|
||||||
const onDeleted = useCallback(() => {
|
const onDeleted = useCallback(() => {
|
||||||
// Go back to feed — the post is gone.
|
// Go back to feed — the post is gone.
|
||||||
router.back();
|
safeBack();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
<Header
|
<Header
|
||||||
divider
|
divider
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
title="Post"
|
title="Post"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { IconButton } from '@/components/IconButton';
|
|||||||
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
|
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
|
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
export default function HashtagScreen() {
|
export default function HashtagScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -80,7 +81,7 @@ export default function HashtagScreen() {
|
|||||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
<Header
|
<Header
|
||||||
divider
|
divider
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
title={`#${tag}`}
|
title={`#${tag}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
|
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
|
||||||
import { shortAddr } from '@/lib/crypto';
|
import { shortAddr } from '@/lib/crypto';
|
||||||
import { formatAmount } from '@/lib/utils';
|
import { formatAmount, safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
@@ -64,9 +64,20 @@ export default function NewContactScreen() {
|
|||||||
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
|
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
|
||||||
address = addr;
|
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 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({
|
setResolved({
|
||||||
address: identity?.pub_key ?? address,
|
address: resolvedAddr,
|
||||||
nickname: identity?.nickname || undefined,
|
nickname: identity?.nickname || undefined,
|
||||||
x25519: identity?.x25519_pub || undefined,
|
x25519: identity?.x25519_pub || undefined,
|
||||||
});
|
});
|
||||||
@@ -79,6 +90,10 @@ export default function NewContactScreen() {
|
|||||||
|
|
||||||
async function sendRequest() {
|
async function sendRequest() {
|
||||||
if (!resolved || !keyFile) return;
|
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) {
|
if (balance < fee + 1000) {
|
||||||
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
|
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
|
||||||
return;
|
return;
|
||||||
@@ -96,7 +111,7 @@ export default function NewContactScreen() {
|
|||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Request sent',
|
'Request sent',
|
||||||
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
|
`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) {
|
} catch (e: any) {
|
||||||
setError(humanizeTxError(e));
|
setError(humanizeTxError(e));
|
||||||
@@ -114,7 +129,7 @@ export default function NewContactScreen() {
|
|||||||
<Header
|
<Header
|
||||||
title="Search"
|
title="Search"
|
||||||
divider={false}
|
divider={false}
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
/>
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
|
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Header } from '@/components/Header';
|
|||||||
import { IconButton } from '@/components/IconButton';
|
import { IconButton } from '@/components/IconButton';
|
||||||
import { followUser, unfollowUser } from '@/lib/feed';
|
import { followUser, unfollowUser } from '@/lib/feed';
|
||||||
import { humanizeTxError } from '@/lib/api';
|
import { humanizeTxError } from '@/lib/api';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
function shortAddr(a: string, n = 10): string {
|
function shortAddr(a: string, n = 10): string {
|
||||||
if (!a) return '—';
|
if (!a) return '—';
|
||||||
@@ -87,7 +88,7 @@ export default function ProfileScreen() {
|
|||||||
<Header
|
<Header
|
||||||
title="Profile"
|
title="Profile"
|
||||||
divider
|
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 }}>
|
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
humanizeTxError,
|
humanizeTxError,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { shortAddr } from '@/lib/crypto';
|
import { shortAddr } from '@/lib/crypto';
|
||||||
import { formatAmount } from '@/lib/utils';
|
import { formatAmount, safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
@@ -335,7 +335,7 @@ export default function SettingsScreen() {
|
|||||||
<Header
|
<Header
|
||||||
title="Settings"
|
title="Settings"
|
||||||
divider={false}
|
divider={false}
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||||
/>
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ paddingBottom: 120 }}
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { generateKeyFile } from '@/lib/crypto';
|
import { generateKeyFile } from '@/lib/crypto';
|
||||||
import { saveKeyFile } from '@/lib/storage';
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
|
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { IconButton } from '@/components/IconButton';
|
import { IconButton } from '@/components/IconButton';
|
||||||
@@ -37,7 +38,7 @@ export default function CreateAccountScreen() {
|
|||||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
<Header
|
<Header
|
||||||
title="Create account"
|
title="Create account"
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
|
||||||
/>
|
/>
|
||||||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
|
||||||
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>
|
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import * as DocumentPicker from 'expo-document-picker';
|
|||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { saveKeyFile } from '@/lib/storage';
|
import { saveKeyFile } from '@/lib/storage';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
|
import { safeBack } from '@/lib/utils';
|
||||||
import type { KeyFile } from '@/lib/types';
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
@@ -96,7 +97,7 @@ export default function ImportKeyScreen() {
|
|||||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||||
<Header
|
<Header
|
||||||
title="Import key"
|
title="Import key"
|
||||||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
|
||||||
/>
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}
|
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx';
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate back, or fall back to a sensible default route if there's
|
||||||
|
* no screen to pop to.
|
||||||
|
*
|
||||||
|
* Without this, an opened route entered via deep link / direct push
|
||||||
|
* (profile, feed/[id], etc.) would emit the "action 'GO_BACK' was not
|
||||||
|
* handled by any navigator" dev warning and do nothing — user ends up
|
||||||
|
* stuck. Default fallback is the chats list (root of the app).
|
||||||
|
*/
|
||||||
|
export function safeBack(fallback: string = '/(app)/chats'): void {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.replace(fallback as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|||||||
Reference in New Issue
Block a user