import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isInteractiveSigner } from '@/lib/signerCapabilities'; /** * Private, encrypted contacts list (Armada-style). * * Stored as a NIP-51 Follow Set (kind 30000) with a fixed `d` identifier. The * contact pubkeys live in the event `content`, NIP-44 encrypted to the user's * own key — so the list never leaks the user's scene/social graph publicly, * unlike a kind-3 follow list. * * This is a "who I skate with" address book that only you can read. */ export const PRIVATE_CONTACTS_D = 'skatespots-contacts'; const CONTACTS_KIND = 30000; interface EncryptedContacts { /** Array of `["p", pubkey]` style tags kept in the encrypted payload. */ tags: string[][]; } /** Fetch and decrypt the user's private contacts (returns hex pubkeys). */ export function usePrivateContacts() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); // Amber prompts on the decrypt below — don't re-run it on every window focus. const interactive = isInteractiveSigner(user, logins); return useQuery({ queryKey: ['private-contacts', user?.pubkey], enabled: !!user?.pubkey, staleTime: interactive ? Infinity : 60 * 1000, refetchOnWindowFocus: false, queryFn: async (c): Promise => { if (!user?.pubkey || !user.signer?.nip44) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await nostr.query( [{ kinds: [CONTACTS_KIND], authors: [user.pubkey], '#d': [PRIVATE_CONTACTS_D], limit: 1 }], { signal }, ); const ev = events[0]; if (!ev) return []; // Public p tags (fallback / merge) + decrypted private ones. const pubkeys = new Set(); for (const [t, v] of ev.tags) { if (t === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) pubkeys.add(v.toLowerCase()); } if (ev.content) { try { const decrypted = await user.signer.nip44.decrypt(user.pubkey, ev.content); const parsed = JSON.parse(decrypted) as EncryptedContacts | string[][]; const tags = Array.isArray(parsed) ? parsed : parsed.tags; for (const [t, v] of tags) { if (t === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) pubkeys.add(v.toLowerCase()); } } catch { // Undecryptable (different key / corrupt) — fall back to public tags. } } return Array.from(pubkeys); }, }); } /** Add / remove a pubkey from the private contacts list. */ export function usePrivateContactActions() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); /** * Resolve the freshest contact list RIGHT BEFORE a mutation. * * Data-safety: the list is one replaceable event whose encrypted content is * the WHOLE set, so add/remove rewrites all of it. Basing the new list on the * React Query cache is unsafe — the cache is legitimately empty until the * Contacts page has decrypted, and with Amber the read query is gated / * staleTime:Infinity, so adding a contact from elsewhere (or on a fresh * device) could publish a list with only that one pubkey and silently wipe * everyone else. So we always re-fetch + decrypt first. * * Prefers a fresh decrypt, then the warm cache, and only returns an empty list * when there's genuinely no remote event. */ const fetchLatest = async (): Promise => { const cached = queryClient.getQueryData(['private-contacts', user?.pubkey]) ?? []; if (!user?.pubkey || !user.signer?.nip44) return cached; try { const events = await nostr.query( [{ kinds: [CONTACTS_KIND], authors: [user.pubkey], '#d': [PRIVATE_CONTACTS_D], limit: 1 }], { signal: AbortSignal.timeout(6000) }, ); const ev = events[0]; if (!ev) return cached; // no remote list yet — cache is all we have const pubkeys = new Set(); for (const [t, v] of ev.tags) { if (t === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) pubkeys.add(v.toLowerCase()); } if (ev.content) { const decrypted = await user.signer.nip44.decrypt(user.pubkey, ev.content); const parsed = JSON.parse(decrypted) as EncryptedContacts | string[][]; const tags = Array.isArray(parsed) ? parsed : parsed.tags; for (const [t, v] of tags) { if (t === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) pubkeys.add(v.toLowerCase()); } } // Union with cache so an optimistic just-added pubkey is never dropped. for (const p of cached) pubkeys.add(p); return Array.from(pubkeys); } catch { // Fetch/decrypt failed — fall back to the cache rather than risk a wipe. return cached; } }; const publishList = async (pubkeys: string[]) => { if (!user?.signer?.nip44) { throw new Error('Your signer does not support NIP-44 encryption.'); } const unique = Array.from(new Set(pubkeys.filter((p) => /^[0-9a-f]{64}$/i.test(p)))); const payload = JSON.stringify(unique.map((p) => ['p', p])); const content = await user.signer.nip44.encrypt(user.pubkey, payload); const event = await user.signer.signEvent({ kind: CONTACTS_KIND, content, created_at: Math.floor(Date.now() / 1000), tags: [ ['d', PRIVATE_CONTACTS_D], ['title', 'My Skate Contacts'], ['description', 'Private, encrypted list of people in my scene.'], ], }); await nostr.event(event); return unique; }; const add = useMutation({ mutationFn: async (pubkey: string) => { const current = await fetchLatest(); return publishList([...current, pubkey]); }, onSuccess: (list) => { queryClient.setQueryData(['private-contacts', user?.pubkey], list); queryClient.invalidateQueries({ queryKey: ['private-contacts', user?.pubkey] }); }, }); const remove = useMutation({ mutationFn: async (pubkey: string) => { const current = await fetchLatest(); return publishList(current.filter((p) => p !== pubkey)); }, onSuccess: (list) => { queryClient.setQueryData(['private-contacts', user?.pubkey], list); queryClient.invalidateQueries({ queryKey: ['private-contacts', user?.pubkey] }); }, }); return { addContact: add.mutateAsync, removeContact: remove.mutateAsync, isAdding: add.isPending, isRemoving: remove.isPending, }; }