180 lines
6.8 KiB
TypeScript
180 lines
6.8 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNostrLogin } from '@/lib/nostrLogin';
|
|
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,
|
|
// Amber: keep the decrypted result for the whole session so navigating back
|
|
// to Contacts doesn't re-prompt (was still re-decrypting after GC).
|
|
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
|
|
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,
|
|
};
|
|
}
|