From a31583079bda398343431e4357d79b5cb26ced73 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 20:36:01 +0700 Subject: [PATCH] Private Contacts: fetch-before-write to prevent list clobber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same encrypted-replaceable-list data-safety pattern as the Stash fix, applied to usePrivateContacts (kind 30000, NIP-44 encrypted to self). add/remove built the new list from the React Query cache, which is empty until the Contacts page has decrypted — and with Amber the read query is gated / staleTime:Infinity. Saving a contact before that decrypt (fresh device, or if a "save privately" action is ever wired up elsewhere) could publish a list with only the new pubkey and silently wipe every other private contact. Fix: add/remove now call fetchLatest() — a fresh relay fetch + decrypt of the current list immediately before publishing — and union it with the warm cache so an optimistic just-added pubkey is never dropped. On fetch/decrypt failure it falls back to the cache rather than risk a wipe. UX: clarified the contacts search placeholder ("Paste an npub to filter…") so it no longer implies name search that the npub-only filter doesn't do. Left the star toggle un-confirmed on purpose — it's a low-stakes, one-tap-reversible action and a dialog would only add friction. ContactsPage is otherwise already clean/elegant (tabbed, skeletons, thoughtful empty states, encryption notice). Bumps to 1.2.24 (Android versionCode 10224 auto-derived). Co-authored-by: shakespeare.diy --- package.json | 2 +- src/hooks/usePrivateContacts.ts | 54 ++++++++++++++++++++++++++++++--- src/pages/ContactsPage.tsx | 2 +- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b049572..994f2bb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.23", + "version": "1.2.24", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/usePrivateContacts.ts b/src/hooks/usePrivateContacts.ts index 965fe58..28006fa 100644 --- a/src/hooks/usePrivateContacts.ts +++ b/src/hooks/usePrivateContacts.ts @@ -76,6 +76,54 @@ export function usePrivateContactActions() { 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.'); @@ -100,8 +148,7 @@ export function usePrivateContactActions() { const add = useMutation({ mutationFn: async (pubkey: string) => { - const current = - queryClient.getQueryData(['private-contacts', user?.pubkey]) ?? []; + const current = await fetchLatest(); return publishList([...current, pubkey]); }, onSuccess: (list) => { @@ -112,8 +159,7 @@ export function usePrivateContactActions() { const remove = useMutation({ mutationFn: async (pubkey: string) => { - const current = - queryClient.getQueryData(['private-contacts', user?.pubkey]) ?? []; + const current = await fetchLatest(); return publishList(current.filter((p) => p !== pubkey)); }, onSuccess: (list) => { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index ac5c0b7..4e2bb6f 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -240,7 +240,7 @@ export default function ContactsPage() { type="text" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Filter by npub…" + placeholder="Paste an npub to filter…" className="h-10 w-full rounded-xl border border-white/10 bg-white/5 pl-9 pr-9 text-sm text-white placeholder:text-gray-600 focus:border-teal-500/50 focus:outline-none" /> {query && (