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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue