SkateSpots/src/hooks/useNewsletterSubscriberCount.ts
shakespeare.diy dc410106b3 Newsletter: subscriber count + admin archive backfill to Nostr
Two follow-ups to the Nostr-native newsletter (skipped auto-DM by design —
too spammy/fragile for now; the bell mailbox already covers the inbox feel).

Subscriber count (social proof):
- useNewsletterSubscriberCount tallies public NIP-78 subscription markers
  (deduped per author, counting current "true" state).
- Shown under the subscribe CTA as "Join N+ skaters getting the drops",
  hidden until it's meaningful (>=3) and rounded once it grows.

Archive backfill (consistency without losing the rich page):
- lib/newsletterArticle.ts converts a static NEWSLETTER_ISSUES entry into a
  NIP-23 (kind 30023) article — structured sections rendered to Markdown,
  stable slug `issue-N`, published_at from the issue date.
- NewsletterBackfillPanel (admin-only) diffs the static archive against what's
  already on-relay and publishes only the missing issues; safe to re-run,
  reports partial failures. The rich static page rendering is untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 19:21:09 +07:00

46 lines
1.8 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { APP_DATA_KIND, NEWSLETTER_SUB_D } from '@/lib/types';
/**
* A best-effort community tally of newsletter subscribers.
*
* Each subscriber owns a public NIP-78 marker (kind 30078, d=NEWSLETTER_SUB_D)
* with a `subscribed` boolean tag. We query those markers and count the ones
* currently set to "true", deduped by author (one vote per pubkey).
*
* This is intentionally approximate — relays only return what they hold — so we
* present it as social proof ("N+ skaters"), not an exact figure.
*/
export function useNewsletterSubscriberCount() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['newsletter-subscriber-count', getQueryKeySuffix()],
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
queryFn: async (c): Promise<number> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[{ kinds: [APP_DATA_KIND], '#d': [NEWSLETTER_SUB_D], limit: 1000 }],
{ signal },
);
// Keep the newest marker per author, then count the "true" ones.
const latestByAuthor = new Map<string, { subscribed: boolean; createdAt: number }>();
for (const ev of events) {
const subscribed = ev.tags.find(([n]) => n === 'subscribed')?.[1] === 'true';
const existing = latestByAuthor.get(ev.pubkey);
if (!existing || ev.created_at > existing.createdAt) {
latestByAuthor.set(ev.pubkey, { subscribed, createdAt: ev.created_at });
}
}
let count = 0;
for (const { subscribed } of latestByAuthor.values()) {
if (subscribed) count++;
}
return count;
},
});
}