SkateSpots/src/hooks/usePrivateContacts.ts
shakespeare.diy a31583079b 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>
2026-09-07 20:36:01 +07:00

177 lines
6.6 KiB
TypeScript

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<string[]> => {
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<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) {
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<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[]) => {
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,
};
}