import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { APP_DATA_KIND, NEWSLETTER_SUB_D } from '@/lib/types'; /** * Per-user SkateSpots newsletter subscription, stored as a NIP-78 * application-specific data event (kind 30078, addressable). Because it's the * user's own replaceable event, the subscribed/unsubscribed state syncs across * every device they log in from. * * The marker carries nothing sensitive — just a `subscribed` boolean tag — so * it's published in plaintext (which also lets the community tally subscribers). * * Subscription is opt-in for the in-app mailbox / notification bell only. * Everyone can read issues on the /newsletter page regardless. */ function subscriptionKey(pubkey: string | undefined) { return ['newsletter-subscription', pubkey] as const; } export function useNewsletterSubscription() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { getWriteRelays } = useSkateRelays(); const queryClient = useQueryClient(); const query = useQuery({ queryKey: subscriptionKey(user?.pubkey), enabled: !!user?.pubkey, staleTime: 60_000, queryFn: async (c): Promise => { if (!user?.pubkey) return false; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); try { const events = await nostr.query( [{ kinds: [APP_DATA_KIND], authors: [user.pubkey], '#d': [NEWSLETTER_SUB_D], limit: 1 }], { signal }, ); const ev = events[0]; if (!ev) return false; const subscribed = ev.tags.find(([n]) => n === 'subscribed')?.[1]; return subscribed === 'true'; } catch { return false; } }, }); const mutation = useMutation({ mutationFn: async (subscribe: boolean) => { if (!user?.pubkey) throw new Error('You must be logged in to subscribe.'); // Optimistic update so the toggle feels instant. queryClient.setQueryData(subscriptionKey(user.pubkey), subscribe); const event = await user.signer.signEvent({ kind: APP_DATA_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags: [ ['d', NEWSLETTER_SUB_D], ['subscribed', subscribe ? 'true' : 'false'], ['alt', 'SkateSpots newsletter subscription'], ], }); // Publish to the COMMUNITY relay (+ the user's own relays), not just the // default pool: the public subscriber tally reads the community relay, so // a marker written only to personal relays would never be counted. const results = await Promise.allSettled( getWriteRelays().map((url) => nostr.relay(url).event(event, { signal: AbortSignal.timeout(10000) }), ), ); if (results.every((r) => r.status === 'rejected')) { throw new Error('Could not reach any relay — check your connection and try again.'); } return subscribe; }, onError: () => { // Roll back optimistic state on failure. queryClient.invalidateQueries({ queryKey: subscriptionKey(user?.pubkey) }); }, onSuccess: (subscribe) => { queryClient.setQueryData(subscriptionKey(user?.pubkey), subscribe); }, }); return { isSubscribed: query.data ?? false, isLoading: query.isLoading, setSubscribed: mutation.mutateAsync, isUpdating: mutation.isPending, }; }