import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isInteractiveSigner } from '@/lib/signerCapabilities'; import { useCallback } from 'react'; /** * Cross-device "dismissed crew notifications" set. * * A crew invite / join-request lives on relays as a public event the recipient * *doesn't* own, so they can't delete it — they can only stop it nagging them. * Previously that was a per-device localStorage set, which meant dismissing on * your phone left it un-dismissed on your laptop. * * This stores the set as a single private, addressable NIP-78 event (kind * 30078) whose content is a NIP-44 payload encrypted to the user's own key — * so it syncs across every device the user logs in from, and never leaks which * invites/requests they ignored. One list covers both invites and join-requests * (both keyed by event id). * * Graceful degradation: the resolved set is always the UNION of the synced * remote list and a local mirror, so dismissals still work instantly, offline, * and on signers without NIP-44 (they just don't sync). */ const DISMISS_KIND = 30078; // NIP-78 application-specific data (addressable) export const DISMISS_D = 'skatespots:crew-notif-dismissals'; const LOCAL_KEY = 'skatespots:dismissed-crew-notifs'; // ─── Local mirror (offline + no-NIP-44 fallback) ────────────────────────────── function readLocal(): string[] { try { const raw = localStorage.getItem(LOCAL_KEY); return raw ? (JSON.parse(raw) as string[]) : []; } catch { return []; } } function writeLocal(ids: string[]) { try { localStorage.setItem(LOCAL_KEY, JSON.stringify(ids)); } catch { // ignore storage errors (private mode, quota, etc.) } } /** Safely read the signer's nip44 without tripping a throwing getter. */ function getNip44(user: ReturnType['user']) { if (!user?.signer) return undefined; try { return user.signer.nip44; } catch { return undefined; } } export function dismissKey(pubkey: string | undefined) { return ['crew-dismissals', pubkey] as const; } /** * Resolve the set of dismissed notification ids for the current user — the * union of the encrypted synced list and the local mirror. */ export function useCrewDismissals() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); // Amber (NIP-55) prompts on every decrypt. This query is mounted GLOBALLY via // the header NotificationBell, so auto-decrypting here would launch Amber on // every navigation/focus — the "Amber flashes on every tap" bug. For an // interactive signer we therefore rely on the instant local mirror ONLY and // never passively decrypt the synced remote list. Dismissals still sync OUT on // the write path (useDismissCrewNotification), and a fresh device simply // starts from an empty local mirror — a minor, acceptable degradation vs. // storming the user with Amber popups just for walking around the app. const interactive = isInteractiveSigner(user, logins); return useQuery({ queryKey: dismissKey(user?.pubkey), // Enabled even when logged out returns just the local mirror (harmless). staleTime: interactive ? Infinity : 30_000, // Never re-run passive decryption on window focus (Amber returning focus // would otherwise re-trigger this and pop Amber again in a loop). refetchOnWindowFocus: false, refetchOnReconnect: !interactive, queryFn: async (c): Promise> => { const local = new Set(readLocal()); if (!user?.pubkey) return local; // Interactive signer (Amber): do NOT decrypt passively. Local mirror only. if (interactive) return local; const nip44 = getNip44(user); // No NIP-44 → can't read the synced list; local mirror is all we have. if (!nip44) return local; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); try { const events = await nostr.query( [{ kinds: [DISMISS_KIND], authors: [user.pubkey], '#d': [DISMISS_D], limit: 1 }], { signal }, ); const ev = events[0]; if (ev?.content) { const decrypted = await nip44.decrypt(user.pubkey, ev.content); const parsed = JSON.parse(decrypted) as { ids?: unknown }; if (Array.isArray(parsed.ids)) { for (const id of parsed.ids) { if (typeof id === 'string') local.add(id); } } } } catch { // Relay hiccup / undecryptable / offline — degrade to the local mirror. } // Keep the local mirror in sync with whatever we resolved. writeLocal([...local]); return local; }, }); } /** * Dismiss a notification by its event id. Optimistically updates the cache + * local mirror immediately, then (best-effort) publishes the encrypted synced * list so other devices catch up. * * INTERACTIVE SIGNERS (Amber): the cross-device encrypted sync is skipped * entirely and the dismissal is kept LOCAL-ONLY. Dismissing is a low-stakes, * high-frequency action (fired on accept/decline of every crew invite and on * every handled join-request), and the encrypted sync costs an extra * encrypt + sign — i.e. one or two additional Amber approval screens per tap. * * Since accepting a crew invite ALREADY requires the membership-attestation * signature (the one that actually makes you a member), forcing a *second* * Amber round-trip just to clear the notification on your other devices is * poor UX and not worth it. So for Amber: * • Accept invite → 1 Amber prompt (the membership attestation only) * • Decline invite → 0 Amber prompts (local dismissal only) * The cross-device "also clear it on my laptop" nicety is silently dropped for * Amber; the dismissal still holds instantly and permanently on this device. * * Non-interactive signers (extension / nsec / bunker) still get the silent * encrypted cross-device sync — it's free for them (no approval screens). */ export function useDismissCrewNotification() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const queryClient = useQueryClient(); const interactive = isInteractiveSigner(user, logins); const mutate = useCallback( async (id: string) => { // 1. Optimistic local update (works for everyone, instantly + offline). const key = dismissKey(user?.pubkey); const current = queryClient.getQueryData>(key) ?? new Set(readLocal()); const next = new Set(current); next.add(id); queryClient.setQueryData(key, next); writeLocal([...next]); // 2. Interactive signer (Amber): stop here — local-only, NO extra prompt. if (interactive) return; // 3. Non-interactive signer: best-effort encrypted NIP-78 cross-device sync. const nip44 = getNip44(user); if (!user?.pubkey || !nip44) return; // no NIP-44: local-only, no throw try { // Data-safety: this list is a single replaceable event whose encrypted // content is the WHOLE dismissal set, so we rewrite all of it. If the // read query hasn't resolved yet (fresh device: cache empty, local // mirror empty), `next` would be just this one id and we'd overwrite — // and thus WIPE — the previously-synced dismissals from other devices. // So merge in the freshest remote set before publishing. (Low-stakes // data — worst case a handled invite re-appears — but we keep the // fetch-before-write pattern consistent with Stash/Contacts.) const merged = new Set(next); try { const events = await nostr.query( [{ kinds: [DISMISS_KIND], authors: [user.pubkey], '#d': [DISMISS_D], limit: 1 }], { signal: AbortSignal.timeout(6000) }, ); const remote = events[0]; if (remote?.content) { const decrypted = await nip44.decrypt(user.pubkey, remote.content); const parsed = JSON.parse(decrypted) as { ids?: unknown }; if (Array.isArray(parsed.ids)) { for (const rid of parsed.ids) { if (typeof rid === 'string') merged.add(rid); } } } } catch { // Couldn't read the remote set — publish the local union anyway; a // dismissal is additive, so at worst we under-merge, never wipe silently. } // Keep local mirror + cache aligned with what we're about to publish. writeLocal([...merged]); queryClient.setQueryData(key, merged); const payload = JSON.stringify({ ids: [...merged] }); const content = await nip44.encrypt(user.pubkey, payload); const event = await user.signer.signEvent({ kind: DISMISS_KIND, content, created_at: Math.floor(Date.now() / 1000), tags: [ ['d', DISMISS_D], ['alt', 'Private: dismissed SkateSpots crew notifications'], ], }); await nostr.event(event, { signal: AbortSignal.timeout(10000) }); } catch { // Publishing failed — the local dismissal still holds on this device. } }, [nostr, user, queryClient, interactive], ); return useMutation({ mutationFn: mutate, onSuccess: () => { queryClient.invalidateQueries({ queryKey: dismissKey(user?.pubkey) }); }, }); }