import { useCallback, useMemo, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { hasNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { APP_DATA_KIND } from '@/lib/types'; import { CORDN_JOIN_TAG, CORDN_WATCHER_PUBKEY, type CordnJoinNotice, } from '@/lib/cordn/watch'; const DISMISSED_KEY = 'skatespots:cordn-join-dismissed'; /** * New Cordn private-chat join requests for this viewer, delivered by the * background watcher as encrypted NIP-78 events. The watcher also sends the * same event as a NIP-17 DM, so the OS push path works while the app is closed. */ export function useCordnJoinNotifications(opened = false) { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const interactive = isInteractiveSigner(user, logins); const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const [dismissed, setDismissed] = useLocalStorage(DISMISSED_KEY, []); // The bell can close while a decrypt loop is running; React Query stops // scheduling new fetches when `enabled` flips, but does not necessarily abort // the current queryFn. Read the live open state inside the loop. const openedRef = useRef(opened); openedRef.current = opened; // Amber prompts once per decrypt. Load these only when the bell is opened; // passive signers can fetch them quietly in the background. const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened); const query = useQuery({ queryKey: ['cordn-join-notifications', user?.pubkey, getQueryKeySuffix()], enabled: canRead, staleTime: 30_000, queryFn: async (c): Promise => { if (!user?.pubkey || !user.signer.nip44) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6_000)]); try { const events = await querySkateContent( [ { kinds: [APP_DATA_KIND], authors: [CORDN_WATCHER_PUBKEY], '#t': [CORDN_JOIN_TAG], '#p': [user.pubkey], limit: 100, }, ], { signal }, ); const notices: CordnJoinNotice[] = []; for (const event of events) { // Closing the bell/unmounting the query must stop further Amber // decrypt prompts; the old loop kept going after navigation. if (!openedRef.current || c.signal.aborted) break; let plain: string; try { plain = event.content.trim().startsWith('{') ? event.content : await user.signer.nip44.decrypt(CORDN_WATCHER_PUBKEY, event.content); } catch { continue; } try { const parsed = JSON.parse(plain) as Partial; if ( typeof parsed.id !== 'string' || typeof parsed.gid !== 'string' || typeof parsed.crewName !== 'string' || typeof parsed.crewPubkey !== 'string' || typeof parsed.crewIdentifier !== 'string' || typeof parsed.requester !== 'string' ) { continue; } const rawAt = typeof parsed.at === 'number' ? parsed.at : event.created_at; notices.push({ id: parsed.id, gid: parsed.gid, crewName: parsed.crewName, crewPubkey: parsed.crewPubkey, crewIdentifier: parsed.crewIdentifier, requester: parsed.requester, at: rawAt > 1e12 ? Math.floor(rawAt / 1000) : rawAt, eventId: event.id, }); } catch { // Ignore malformed notices. } } const seen = new Set(); return notices .filter((notice) => { if (seen.has(notice.id)) return false; seen.add(notice.id); return true; }) .sort((a, b) => b.at - a.at) .slice(0, 30); } catch { return []; } }, }); const dismissedSet = useMemo(() => new Set(dismissed), [dismissed]); const requests = useMemo( () => (query.data ?? []).filter((notice) => !dismissedSet.has(notice.id)), [query.data, dismissedSet], ); const dismiss = useCallback( (id: string) => { setDismissed((current) => (current.includes(id) ? current : [...current, id].slice(-200))); }, [setDismissed], ); return { requests, isLoading: query.isLoading, dismiss, }; }