Audit of the newsletter section found two real bugs.
1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
-> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
markAllRead changed every render -> the page effect (deps include it) re-ran
after every render -> the mark set a NEW state object -> loop. The
notification bell looped the same way while open.
- useLocalStorage: setter is now referentially stable (ref + functional set).
- useSyncedAppData: returned object is memoized.
- useReadState: apply reads synced/interactive from refs (stable); marks are
idempotent (same object when already marked) so React bails.
- NewsletterPage marks read once per visit (mount-once ref guard).
2) Issues + subscription markers were published via the default pool (the
author's own NIP-65 relays) but READ via useSkateRelays (community relay +
reader's relays; logged-out = community relay only), so an admin without
goskatespots in their list would publish drops nobody could see. Composer,
backfill panel and the subscribe hook now publish to getWriteRelays() and
fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
(no schema change).
Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
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<boolean> => {
|
|
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,
|
|
};
|
|
}
|