diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx index 31ab37f..5806328 100644 --- a/src/components/CrewChat.tsx +++ b/src/components/CrewChat.tsx @@ -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(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(); diff --git a/src/components/CrewRequestToJoin.tsx b/src/components/CrewRequestToJoin.tsx index a4c7855..0d8dc36 100644 --- a/src/components/CrewRequestToJoin.tsx +++ b/src/components/CrewRequestToJoin.tsx @@ -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) { diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 621bd27..579d86b 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -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, }); diff --git a/src/hooks/useStash.ts b/src/hooks/useStash.ts index 532a2d0..5bb0884 100644 --- a/src/hooks/useStash.ts +++ b/src/hooks/useStash.ts @@ -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, }; } diff --git a/src/lib/signerCapabilities.ts b/src/lib/signerCapabilities.ts new file mode 100644 index 0000000..0647493 --- /dev/null +++ b/src/lib/signerCapabilities.ts @@ -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; +} diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 7882e3a..ac5c0b7 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -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(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.', diff --git a/src/pages/ConversationPage.tsx b/src/pages/ConversationPage.tsx index 38a0997..84d1039 100644 --- a/src/pages/ConversationPage.tsx +++ b/src/pages/ConversationPage.tsx @@ -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(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); diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index 076098a..a650dee 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -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;