SkateSpots/src/hooks/useNewsletterIssues.ts
shakespeare.diy 08f74d7991 Add Nostr-native newsletter: subscribe, mailbox & notifications
Turn the static newsletter into a live, Nostr-native marketing channel that
npubs can subscribe to and receive in-app.

Publishing (admins):
- Newsletter issues are published as NIP-23 long-form articles (kind 30023)
  tagged `skatespotsnews`, on the community relay.
- NewsletterComposer: admin-only dialog to write + publish an issue (title,
  slug, summary, cover image, Markdown body).

Subscribing (any npub):
- useNewsletterSubscription: per-user opt-in stored as a NIP-78 (kind 30078)
  marker, so it syncs across every device the user logs into.
- NewsletterSubscribeButton on the /newsletter page and issue reader.

Mailbox / notifications:
- New "News" tab in the NotificationBell showing all issues, with an unread
  badge for subscribers (tracked via a per-account last-read timestamp).
  Latest unread drop also surfaces in the "All" tab.
- Opening the bell or the /newsletter page clears the News badge.

Reading:
- useNewsletterIssues: queries admin-authored issues (author-scoped + tag +
  admin re-validation as the trust boundary against spam).
- LiveIssuesSection on /newsletter shows issue cards linking to a reader.
- NewsletterIssueView: naddr reader (wired into NIP19Page for kind 30023) with
  a small safe Markdown renderer, zap + subscribe CTAs.

Docs: NIP.md documents the 30023 issue schema, the 30078 subscription marker,
and the admin trust boundary.

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

60 lines
2 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
NEWSLETTER_KIND,
NEWSLETTER_TAG,
parseNewsletterIssue,
type SkateNewsletterIssue,
} from '@/lib/types';
/**
* Live SkateSpots newsletter issues, published by app admins as NIP-23
* long-form articles (kind 30023) tagged `skatespotsnews`.
*
* Trust: the query is scoped to `authors: ADMIN_PUBKEYS` at the relay level AND
* re-validated client-side via `parseNewsletterIssue` (which re-checks the
* admin allowlist + the newsletter tag) so a rogue relay can't inject issues.
*
* Sorted newest-first by `published_at`.
*/
export function useNewsletterIssues(limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['newsletter-issues', getQueryKeySuffix()],
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
queryFn: async (c): Promise<SkateNewsletterIssue[]> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[
{
kinds: [NEWSLETTER_KIND],
authors: ADMIN_PUBKEYS,
'#t': [NEWSLETTER_TAG],
limit,
},
],
{ signal },
);
const issues: SkateNewsletterIssue[] = [];
for (const ev of events) {
const parsed = parseNewsletterIssue(ev);
if (parsed) issues.push(parsed);
}
// Dedupe by addressable coordinate (pubkey:d) keeping the newest.
const byCoord = new Map<string, SkateNewsletterIssue>();
for (const issue of issues) {
const key = `${issue.pubkey}:${issue.identifier}`;
const existing = byCoord.get(key);
if (!existing || issue.createdAt > existing.createdAt) byCoord.set(key, issue);
}
return Array.from(byCoord.values()).sort((a, b) => b.publishedAt - a.publishedAt);
},
});
}