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>
60 lines
2 KiB
TypeScript
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);
|
|
},
|
|
});
|
|
}
|