fix: eliminate all render-path nip44 getter crashes (Browser extension not available)
Root cause: NUser's extension signer exposes .nip44 as a getter that calls .nostr (window.nostr) and THROWS when the extension isn't present. Any synchronous access during render crashes the whole tree — the actual culprit was useStash's `isEncryptionAvailable: !!user?.signer?.nip44` in its return object, evaluated every render even when logged out. - Add src/lib/signerCapabilities.ts: hasNip44/hasNip04 helpers that determine capability via login type + direct window.nostr probe (no throwing getter). - useStash: use the helper for hasNip44 + isEncryptionAvailable - useDirectMessages: replace all render-path `enabled: !!user?.signer?.nip44` with a pre-computed nip44Available across all 4 conversation hooks - CrewChat, CrewRequestToJoin, ConversationPage, DirectMessagesPage, ContactsPage: use the safe helper in render/guard paths Async accesses (inside queryFn/callbacks) are left as-is — they reject gracefully within react-query and never crash render. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
73f51662af
commit
0ac5c1019d
8 changed files with 93 additions and 25 deletions
|
|
@ -12,6 +12,8 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
|
|
@ -112,6 +114,7 @@ export function CrewChat({
|
|||
canModerate = false,
|
||||
}: CrewChatProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { toast } = useToast();
|
||||
const [draft, setDraft] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -165,7 +168,7 @@ export function CrewChat({
|
|||
}
|
||||
}, [messages]);
|
||||
|
||||
const canSendNip44 = !!user?.signer?.nip44;
|
||||
const canSendNip44 = checkNip44(user, logins);
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import {
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { UserPlus, Loader2, Lock } from 'lucide-react';
|
||||
|
||||
interface CrewRequestToJoinProps {
|
||||
|
|
@ -32,6 +34,7 @@ interface CrewRequestToJoinProps {
|
|||
*/
|
||||
export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -46,7 +49,7 @@ export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: Crew
|
|||
// Don't show for people already in the leadership.
|
||||
if (recipients.length === 0) return null;
|
||||
|
||||
const canDM = !!user.signer?.nip44;
|
||||
const canDM = checkNip44(user, logins);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canDM) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { nip44, nip19, generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useAppContext } from './useAppContext';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
|
||||
// NIP-17 Message Types
|
||||
export interface DirectMessage {
|
||||
|
|
@ -229,7 +231,9 @@ export function useSendTradeDM() {
|
|||
export function useConversation(otherPubkey: string) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { config } = useAppContext();
|
||||
const nip44Available = checkNip44(user, logins);
|
||||
|
||||
const relayUrls = config.relayMetadata.relays
|
||||
.filter(r => r.read)
|
||||
|
|
@ -326,7 +330,7 @@ export function useConversation(otherPubkey: string) {
|
|||
// Sort by creation time (oldest first for chat display)
|
||||
return messages.sort((a, b) => a.createdAt - b.createdAt);
|
||||
},
|
||||
enabled: !!user?.signer?.nip44 && !!otherPubkey,
|
||||
enabled: nip44Available && !!otherPubkey,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 30000, // Poll for new messages
|
||||
});
|
||||
|
|
@ -338,7 +342,9 @@ export function useConversation(otherPubkey: string) {
|
|||
export function useConversations() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { config } = useAppContext();
|
||||
const nip44Available = checkNip44(user, logins);
|
||||
|
||||
const relayUrls = config.relayMetadata.relays
|
||||
.filter(r => r.read)
|
||||
|
|
@ -450,7 +456,7 @@ export function useConversations() {
|
|||
(b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0)
|
||||
);
|
||||
},
|
||||
enabled: !!user?.signer?.nip44,
|
||||
enabled: nip44Available,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000, // Poll for new conversations
|
||||
});
|
||||
|
|
@ -475,7 +481,9 @@ export function formatPubkeyShort(pubkey: string): string {
|
|||
export function useGroupConversations() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { config } = useAppContext();
|
||||
const nip44Available = checkNip44(user, logins);
|
||||
|
||||
const relayUrls = config.relayMetadata.relays
|
||||
.filter(r => r.read)
|
||||
|
|
@ -598,7 +606,7 @@ export function useGroupConversations() {
|
|||
(b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0)
|
||||
);
|
||||
},
|
||||
enabled: !!user?.signer?.nip44,
|
||||
enabled: nip44Available,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
|
@ -609,8 +617,10 @@ export function useGroupConversations() {
|
|||
*/
|
||||
export function useGroupConversation(participants: string[]) {
|
||||
const { nostr } = useNostr();
|
||||
const { logins } = useNostrLogin();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const nip44Available = checkNip44(user, logins);
|
||||
|
||||
const groupId = getGroupId(participants);
|
||||
|
||||
|
|
@ -702,7 +712,7 @@ export function useGroupConversation(participants: string[]) {
|
|||
// Sort by creation time (oldest first for chat display)
|
||||
return messages.sort((a, b) => a.createdAt - b.createdAt);
|
||||
},
|
||||
enabled: !!user?.signer?.nip44 && participants.length > 0,
|
||||
enabled: nip44Available && participants.length > 0,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useNostr } from '@nostrify/react';
|
|||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
|
||||
// Stash item stored in encrypted NIP-51 list
|
||||
export interface StashItem {
|
||||
|
|
@ -53,21 +54,7 @@ export function useStash() {
|
|||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
// Determine if nip44 is safely available WITHOUT touching any throwing getter.
|
||||
// Extension signers throw "Browser extension not available" from their .nostr
|
||||
// getter when window.nostr isn't present yet (page load race, or no extension).
|
||||
// We check the login type instead: nsec and bunker have nip44 built-in always;
|
||||
// extension logins only have it when window.nostr is present; amber never has it.
|
||||
const hasNip44 = useMemo(() => {
|
||||
if (!user?.pubkey) return false;
|
||||
const login = logins.find((l) => l.pubkey === user.pubkey);
|
||||
if (!login) return false;
|
||||
// nsec and bunker: nip44 is always available (pure JS, no external dep)
|
||||
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
||||
// extension: safe to check window.nostr directly (no getter, no throw)
|
||||
if (login.type === 'extension') return !!(globalThis as { nostr?: { nip44?: unknown } }).nostr?.nip44;
|
||||
// amber / unknown: no nip44 support
|
||||
return false;
|
||||
}, [user?.pubkey, logins]);
|
||||
const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]);
|
||||
|
||||
// Fetch and decrypt stash
|
||||
const { data: stash, isLoading, error } = useQuery({
|
||||
|
|
@ -223,7 +210,7 @@ export function useStash() {
|
|||
addItem,
|
||||
updateItem,
|
||||
removeItem,
|
||||
isEncryptionAvailable: !!user?.signer?.nip44,
|
||||
isEncryptionAvailable: hasNip44,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
56
src/lib/signerCapabilities.ts
Normal file
56
src/lib/signerCapabilities.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* signerCapabilities — safely probe what a Nostr signer can do.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* The NUser extension signer exposes `.nip44` / `.nip04` as GETTERS that call
|
||||
* `.nostr` (window.nostr) and THROW "Browser extension not available" when the
|
||||
* extension isn't present. Accessing `user.signer?.nip44` during render will
|
||||
* therefore crash the whole React tree for extension logins whose extension
|
||||
* isn't injected yet (page-load race) or is missing entirely.
|
||||
*
|
||||
* These helpers determine capability WITHOUT touching the throwing getter:
|
||||
* we look at the login type + probe `window.nostr` directly (a plain property
|
||||
* read that never throws). Use `hasNip44(user, logins)` anywhere you'd
|
||||
* otherwise write `!!user?.signer?.nip44` in a render path.
|
||||
*/
|
||||
|
||||
interface MaybeUser {
|
||||
pubkey?: string;
|
||||
}
|
||||
|
||||
interface MaybeLogin {
|
||||
type: string;
|
||||
pubkey?: string;
|
||||
}
|
||||
|
||||
function windowNostr(): { nip44?: unknown; nip04?: unknown } | undefined {
|
||||
return (globalThis as { nostr?: { nip44?: unknown; nip04?: unknown } }).nostr;
|
||||
}
|
||||
|
||||
/** True if the user's signer can do NIP-44 encryption. Never throws. */
|
||||
export function hasNip44(
|
||||
user: MaybeUser | null | undefined,
|
||||
logins: readonly MaybeLogin[],
|
||||
): boolean {
|
||||
if (!user?.pubkey) return false;
|
||||
const login = logins.find((l) => l.pubkey === user.pubkey);
|
||||
if (!login) return false;
|
||||
// nsec + bunker: NIP-44 always available (pure JS / remote signer implements it)
|
||||
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
||||
// extension: probe window.nostr directly (no throwing getter)
|
||||
if (login.type === 'extension') return !!windowNostr()?.nip44;
|
||||
// amber / unknown: no NIP-44
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if the user's signer can do NIP-04 encryption. Never throws. */
|
||||
export function hasNip04(
|
||||
user: MaybeUser | null | undefined,
|
||||
logins: readonly MaybeLogin[],
|
||||
): boolean {
|
||||
if (!user?.pubkey) return false;
|
||||
const login = logins.find((l) => l.pubkey === user.pubkey);
|
||||
if (!login) return false;
|
||||
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
||||
if (login.type === 'extension') return !!windowNostr()?.nip04;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useFollowing } from '@/hooks/useFollowing';
|
||||
import { useConversations } from '@/hooks/useDirectMessages';
|
||||
|
|
@ -119,6 +121,7 @@ export default function ContactsPage() {
|
|||
});
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
|
|
@ -132,7 +135,7 @@ export default function ContactsPage() {
|
|||
const [pendingPubkey, setPendingPubkey] = useState<string | null>(null);
|
||||
|
||||
const toggleSave = async (pubkey: string) => {
|
||||
if (!user?.signer?.nip44) {
|
||||
if (!checkNip44(user, logins)) {
|
||||
toast({
|
||||
title: 'Encryption unavailable',
|
||||
description: 'Your signer does not support NIP-44 encryption.',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Textarea } from '@/components/ui/textarea';
|
|||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -66,6 +68,7 @@ export default function ConversationPage() {
|
|||
const { npub } = useParams<{ npub: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Support pre-filled message from URL query param
|
||||
|
|
@ -151,7 +154,7 @@ export default function ConversationPage() {
|
|||
}
|
||||
|
||||
// Check NIP-44 support
|
||||
if (!user.signer?.nip44) {
|
||||
if (!checkNip44(user, logins)) {
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
const isMobile = isAndroid || /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import {
|
|||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useConversations, formatPubkeyShort } from '@/hooks/useDirectMessages';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -135,6 +137,7 @@ function ConversationSkeleton() {
|
|||
|
||||
export default function DirectMessagesPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { data: conversations, isLoading, error } = useConversations();
|
||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||
const [newRecipient, setNewRecipient] = useState('');
|
||||
|
|
@ -190,7 +193,7 @@ export default function DirectMessagesPage() {
|
|||
}
|
||||
|
||||
// Check if NIP-44 is available
|
||||
if (!user.signer?.nip44) {
|
||||
if (!checkNip44(user, logins)) {
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
const isMobile = isAndroid || isIOS;
|
||||
|
|
|
|||
Loading…
Reference in a new issue