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:
parent
29d41b81eb
commit
a31583079b
3 changed files with 52 additions and 6 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<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.');
|
||||
|
|
@ -100,8 +148,7 @@ export function usePrivateContactActions() {
|
|||
|
||||
const add = useMutation({
|
||||
mutationFn: async (pubkey: string) => {
|
||||
const current =
|
||||
queryClient.getQueryData<string[]>(['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<string[]>(['private-contacts', user?.pubkey]) ?? [];
|
||||
const current = await fetchLatest();
|
||||
return publishList(current.filter((p) => p !== pubkey));
|
||||
},
|
||||
onSuccess: (list) => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue