From 08f74d799112f35317bc0e0c8f8c58f4f43e0f87 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 19:16:49 +0700 Subject: [PATCH] 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 --- NIP.md | 61 ++++++ src/components/NewsletterComposer.tsx | 187 +++++++++++++++++ src/components/NewsletterIssueView.tsx | 210 +++++++++++++++++++ src/components/NewsletterSubscribeButton.tsx | 104 +++++++++ src/components/NotificationBell.tsx | 141 ++++++++++++- src/hooks/useNewsletterIssues.ts | 60 ++++++ src/hooks/useNewsletterRead.ts | 20 ++ src/hooks/useNewsletterSubscription.ts | 85 ++++++++ src/lib/types.ts | 64 ++++++ src/pages/NIP19Page.tsx | 13 +- src/pages/NewsletterPage.tsx | 102 ++++++++- 11 files changed, 1031 insertions(+), 16 deletions(-) create mode 100644 src/components/NewsletterComposer.tsx create mode 100644 src/components/NewsletterIssueView.tsx create mode 100644 src/components/NewsletterSubscribeButton.tsx create mode 100644 src/hooks/useNewsletterIssues.ts create mode 100644 src/hooks/useNewsletterRead.ts create mode 100644 src/hooks/useNewsletterSubscription.ts diff --git a/NIP.md b/NIP.md index fc71829..fa543c5 100644 --- a/NIP.md +++ b/NIP.md @@ -2904,3 +2904,64 @@ pubkeys always have Pro. decrypted locally by the signer and never sent to any AI or server. - Free Sasquatch remains fully usable (public spot/event/coach/gear/crew search plus the trick glossary); Pro adds the personal + action layer. + +## Newsletter (kind 30023) + Subscription (kind 30078) + +SkateSpots publishes a community newsletter as standard **NIP-23 long-form +articles (kind 30023)**, so issues are readable in any Nostr long-form client +(Habla, Highlighter, etc.) and by the app's in-app mailbox. + +### Trust boundary + +An issue is only surfaced in the app (mailbox / notification bell) if it is: + +1. **Kind 30023**, and +2. authored by a **SkateSpots admin pubkey** (see `src/lib/admins.ts`), and +3. tagged `["t", "skatespotsnews"]`. + +This prevents anyone from spamming the community newsletter feed. + +### Issue format + +```json +{ + "kind": 30023, + "content": "", + "tags": [ + ["d", "issue-6"], + ["title", "SkateSpots Drop #006"], + ["summary", "One-line teaser of the issue"], + ["image", ""], + ["published_at", ""], + ["t", "skatespotsnews"], + ["t", "skatespots"], + ["t", "newsletter"], + ["alt", "SkateSpots newsletter issue"] + ] +} +``` + +### Subscription marker (kind 30078) + +A reader opts in to the in-app newsletter mailbox by publishing a per-user +**NIP-78 application-specific data** event (kind 30078, addressable): + +```json +{ + "kind": 30078, + "content": "", + "tags": [ + ["d", "skatespots:newsletter:subscription"], + ["subscribed", "true"], + ["alt", "SkateSpots newsletter subscription"] + ] +} +``` + +- `subscribed` is `"true"` while subscribed and `"false"` after unsubscribing + (the event is replaced, not deleted, so state syncs across devices). +- It carries nothing sensitive (just a boolean), so it is published in + plaintext, which also lets the community tally an honest subscriber count. +- Subscription is opt-in for notifications only: everyone can read issues on + the `/newsletter` page regardless. Subscribing routes new issues into the + notification bell's "News" tab and unread badge. diff --git a/src/components/NewsletterComposer.tsx b/src/components/NewsletterComposer.tsx new file mode 100644 index 0000000..662672b --- /dev/null +++ b/src/components/NewsletterComposer.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { PenLine, Send, Loader2, Sparkles } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import { isAdminPubkey } from '@/lib/admins'; +import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types'; + +/** Slugify a title into a stable `d` identifier. */ +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 60); +} + +/** + * Admin-only composer that publishes a newsletter issue as a NIP-23 long-form + * article (kind 30023) tagged `skatespotsnews`. Subscribers then receive it in + * their notification bell's "News" tab and it appears on the /newsletter page. + */ +export function NewsletterComposer() { + const { user } = useCurrentUser(); + const { mutateAsync: publish, isPending } = useNostrPublish(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(''); + const [slug, setSlug] = useState(''); + const [summary, setSummary] = useState(''); + const [image, setImage] = useState(''); + const [body, setBody] = useState(''); + + if (!isAdminPubkey(user?.pubkey)) return null; + + const effectiveSlug = slug.trim() || slugify(title); + const canPublish = title.trim().length >= 2 && body.trim().length >= 10 && !!effectiveSlug; + + const handlePublish = async () => { + if (!canPublish) return; + const now = Math.floor(Date.now() / 1000); + try { + await publish({ + kind: NEWSLETTER_KIND, + content: body, + tags: [ + ['d', effectiveSlug], + ['title', title.trim()], + ...(summary.trim() ? [['summary', summary.trim()]] : []), + ...(image.trim() ? [['image', image.trim()]] : []), + ['published_at', String(now)], + ['t', NEWSLETTER_TAG], + ['t', 'skatespots'], + ['t', 'newsletter'], + ['t', 'skateboarding'], + ['alt', `SkateSpots newsletter issue: ${title.trim()}`], + ], + }); + toast({ + title: 'Issue published! πŸ›Ή', + description: 'Subscribers will see it in their notification bell.', + }); + queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] }); + setOpen(false); + setTitle(''); + setSlug(''); + setSummary(''); + setImage(''); + setBody(''); + } catch (err) { + toast({ + title: 'Publish failed', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + + return ( + + + + + + + + Publish a newsletter issue + + + Published as a NIP-23 article (kind 30023) on the community relay. Subscribers get it in + their notification bell. Body supports Markdown. + + + +
+
+ + setTitle(e.target.value)} + placeholder="SkateSpots Drop #006" + maxLength={120} + className="mt-1" + /> +
+ +
+ + setSlug(e.target.value)} + placeholder={slugify(title) || 'issue-6'} + maxLength={60} + className="mt-1 font-mono text-sm" + /> +

+ Re-using an existing slug edits that issue instead of creating a new one. +

+
+ +
+ + setSummary(e.target.value)} + placeholder="One-line teaser shown in the mailbox & cards" + maxLength={200} + className="mt-1" + /> +
+ +
+ + setImage(e.target.value)} + placeholder="https://…" + className="mt-1" + /> +
+ +
+ +