Private Contacts: fetch-before-write to prevent list clobber

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 20:36:01 +07:00
parent 29d41b81eb
commit a31583079b
3 changed files with 52 additions and 6 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.2.23", "version": "1.2.24",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -76,6 +76,54 @@ export function usePrivateContactActions() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const queryClient = useQueryClient(); 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<string[]> => {
const cached =
queryClient.getQueryData<string[]>(['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<string>();
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[]) => { const publishList = async (pubkeys: string[]) => {
if (!user?.signer?.nip44) { if (!user?.signer?.nip44) {
throw new Error('Your signer does not support NIP-44 encryption.'); throw new Error('Your signer does not support NIP-44 encryption.');
@ -100,8 +148,7 @@ export function usePrivateContactActions() {
const add = useMutation({ const add = useMutation({
mutationFn: async (pubkey: string) => { mutationFn: async (pubkey: string) => {
const current = const current = await fetchLatest();
queryClient.getQueryData<string[]>(['private-contacts', user?.pubkey]) ?? [];
return publishList([...current, pubkey]); return publishList([...current, pubkey]);
}, },
onSuccess: (list) => { onSuccess: (list) => {
@ -112,8 +159,7 @@ export function usePrivateContactActions() {
const remove = useMutation({ const remove = useMutation({
mutationFn: async (pubkey: string) => { mutationFn: async (pubkey: string) => {
const current = const current = await fetchLatest();
queryClient.getQueryData<string[]>(['private-contacts', user?.pubkey]) ?? [];
return publishList(current.filter((p) => p !== pubkey)); return publishList(current.filter((p) => p !== pubkey));
}, },
onSuccess: (list) => { onSuccess: (list) => {

View file

@ -240,7 +240,7 @@ export default function ContactsPage() {
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} 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" 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 && ( {query && (