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>
177 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|