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>
This commit is contained in:
shakespeare.diy 2026-09-03 19:16:49 +07:00
parent cdf8cee3a6
commit 08f74d7991
11 changed files with 1031 additions and 16 deletions

61
NIP.md
View file

@ -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": "<Markdown body of the issue>",
"tags": [
["d", "issue-6"],
["title", "SkateSpots Drop #006"],
["summary", "One-line teaser of the issue"],
["image", "<cover image url>"],
["published_at", "<unix seconds>"],
["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.

View file

@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<PenLine className="h-4 w-4" /> New issue
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg w-[calc(100vw-2rem)] bg-black border-2 border-[#ccff00]/30">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Sparkles className="h-4 w-4 text-[#ccff00]" /> Publish a newsletter issue
</DialogTitle>
<DialogDescription className="text-xs">
Published as a NIP-23 article (kind 30023) on the community relay. Subscribers get it in
their notification bell. Body supports Markdown.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-400">Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="SkateSpots Drop #006"
maxLength={120}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-gray-400">
Identifier (slug) <span className="text-gray-600">· leave blank to auto-generate</span>
</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={slugify(title) || 'issue-6'}
maxLength={60}
className="mt-1 font-mono text-sm"
/>
<p className="mt-1 text-[10px] text-gray-600">
Re-using an existing slug edits that issue instead of creating a new one.
</p>
</div>
<div>
<Label className="text-xs text-gray-400">Summary <span className="text-gray-600">· optional</span></Label>
<Input
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="One-line teaser shown in the mailbox & cards"
maxLength={200}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-gray-400">Cover image URL <span className="text-gray-600">· optional</span></Label>
<Input
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder="https://…"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-gray-400">Body (Markdown)</Label>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={'## What shipped\n\n- New thing one\n- New thing two\n\nRead more at **skate-spots.org**.'}
className="mt-1 min-h-[200px] font-mono text-sm leading-relaxed"
/>
<p className="mt-1 text-right text-[10px] text-gray-600">{body.length} characters</p>
</div>
<Button
onClick={handlePublish}
disabled={!canPublish || isPending}
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Publish issue
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,210 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Radio, Calendar } from 'lucide-react';
import { Layout } from '@/components/Layout';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ZapButton } from '@/components/ZapButton';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { NEWSLETTER_KIND, parseNewsletterIssue, type SkateNewsletterIssue } from '@/lib/types';
import NotFound from '@/pages/NotFound';
/**
* Very small, safe Markdown → React renderer for newsletter bodies.
* Supports headings, bold, italics, links, list items and paragraphs — enough
* for a newsletter without pulling in a full Markdown dependency. All text is
* rendered as plain React text nodes (never dangerouslySetInnerHTML), and only
* http(s) links are linkified, so there's no XSS surface from issue content.
*/
function renderMarkdown(md: string) {
const lines = md.split('\n');
const blocks: React.ReactNode[] = [];
let key = 0;
const renderInline = (text: string): React.ReactNode[] => {
const nodes: React.ReactNode[] = [];
// Tokenise **bold**, *italic*, and [label](url) / bare http(s) links.
const regex = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\[([^\]]+)\]\((https?:\/\/[^\s)]+)\))|(https?:\/\/[^\s]+)/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = regex.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index));
if (m[1]) {
nodes.push(<strong key={i++} className="font-black text-white">{m[2]}</strong>);
} else if (m[3]) {
nodes.push(<em key={i++} className="italic">{m[4]}</em>);
} else if (m[5]) {
nodes.push(
<a key={i++} href={m[7]} target="_blank" rel="noopener noreferrer" className="text-[#ccff00] underline underline-offset-2 hover:opacity-80">
{m[6]}
</a>,
);
} else if (m[8]) {
nodes.push(
<a key={i++} href={m[8]} target="_blank" rel="noopener noreferrer" className="text-[#ccff00] underline underline-offset-2 hover:opacity-80 break-all">
{m[8]}
</a>,
);
}
last = regex.lastIndex;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
};
let listBuffer: string[] = [];
const flushList = () => {
if (listBuffer.length === 0) return;
blocks.push(
<ul key={key++} className="my-4 list-disc space-y-1.5 pl-5 text-gray-300">
{listBuffer.map((item, idx) => (
<li key={idx}>{renderInline(item)}</li>
))}
</ul>,
);
listBuffer = [];
};
for (const raw of lines) {
const line = raw.trimEnd();
if (/^\s*[-*]\s+/.test(line)) {
listBuffer.push(line.replace(/^\s*[-*]\s+/, ''));
continue;
}
flushList();
if (line.trim() === '') continue;
if (line.startsWith('### ')) {
blocks.push(<h3 key={key++} className="mt-6 mb-2 text-lg font-black uppercase text-white">{renderInline(line.slice(4))}</h3>);
} else if (line.startsWith('## ')) {
blocks.push(<h2 key={key++} className="mt-8 mb-3 text-xl font-black uppercase text-white">{renderInline(line.slice(3))}</h2>);
} else if (line.startsWith('# ')) {
blocks.push(<h1 key={key++} className="mt-8 mb-3 text-2xl font-black uppercase text-white">{renderInline(line.slice(2))}</h1>);
} else {
blocks.push(<p key={key++} className="my-3 leading-relaxed text-gray-300">{renderInline(line)}</p>);
}
}
flushList();
return blocks;
}
export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; identifier: string }) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const author = useAuthor(pubkey);
const { data: issue, isLoading } = useQuery({
queryKey: ['newsletter-issue', pubkey, identifier, getQueryKeySuffix()],
staleTime: 5 * 60 * 1000,
queryFn: async (c): Promise<SkateNewsletterIssue | null> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[{ kinds: [NEWSLETTER_KIND], authors: [pubkey], '#d': [identifier], limit: 1 }],
{ signal },
);
for (const ev of events) {
const parsed = parseNewsletterIssue(ev);
if (parsed) return parsed;
}
return null;
},
});
const body = useMemo(() => (issue ? renderMarkdown(issue.content) : null), [issue]);
useSeoMeta({
title: issue ? `${issue.title} - SkateSpots Newsletter` : 'Newsletter - SkateSpots',
description: issue?.summary || 'A SkateSpots newsletter drop.',
...(issue?.image ? { ogImage: issue.image } : {}),
});
if (isLoading) {
return (
<Layout>
<div className="container max-w-2xl px-4 py-8">
<Skeleton className="mb-6 h-6 w-32" />
<Skeleton className="mb-4 aspect-video w-full rounded-2xl" />
<Skeleton className="mb-3 h-10 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
</Layout>
);
}
// Not a valid / admin-authored newsletter issue → 404 (keeps the trust boundary).
if (!issue) return <NotFound />;
const authorName = author.data?.metadata?.name ?? genUserName(pubkey);
const authorPic = author.data?.metadata?.picture;
const date = new Date(issue.publishedAt * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<Layout>
<article className="container max-w-2xl px-4 py-8 pb-24 md:pb-14">
<Link
to="/newsletter"
className="mb-6 inline-flex items-center gap-2 text-sm text-gray-400 transition-colors hover:text-[#ccff00]"
>
<ArrowLeft className="h-4 w-4" /> All issues
</Link>
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
<Radio className="h-3 w-3" /> SkateSpots Newsletter
</div>
{issue.image && (
<div className="mb-6 aspect-video w-full overflow-hidden rounded-2xl border border-white/10">
<img src={issue.image} alt={issue.title} className="h-full w-full object-cover" />
</div>
)}
<h1 className="mb-3 text-3xl font-black uppercase leading-tight text-white sm:text-4xl">
{issue.title}
</h1>
<div className="mb-6 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" /> {date}
</span>
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-1.5 hover:text-gray-300">
<Avatar className="h-5 w-5">
<AvatarImage src={authorPic} alt={authorName} />
<AvatarFallback className="text-[8px]">{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{authorName}
</Link>
</div>
{issue.summary && (
<p className="mb-6 border-l-2 border-[#ccff00]/40 pl-4 text-lg leading-relaxed text-gray-300">
{issue.summary}
</p>
)}
<div className="prose-invert">{body}</div>
{/* Footer CTA: zap the issue + subscribe */}
<div className="mt-10 flex flex-col items-center gap-4 rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] p-6 text-center">
<p className="text-sm font-bold text-white">Dig the drop? Zap it and never miss the next one.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<ZapButton target={issue.event} className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" />
<NewsletterSubscribeButton />
</div>
</div>
</article>
</Layout>
);
}

View file

@ -0,0 +1,104 @@
import { useState } from 'react';
import { Bell, BellOff, Loader2, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { LoginArea } from '@/components/auth/LoginArea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
/**
* Opt-in / opt-out of the SkateSpots newsletter mailbox. When subscribed, new
* issues surface in the notification bell's "News" tab. Logged-out users see a
* login prompt instead.
*/
export function NewsletterSubscribeButton({ className }: { className?: string }) {
const { user } = useCurrentUser();
const { isSubscribed, isLoading, setSubscribed, isUpdating } = useNewsletterSubscription();
const { toast } = useToast();
const [justSubscribed, setJustSubscribed] = useState(false);
if (!user) {
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<p className="text-xs text-gray-500">Log in with Nostr to get new drops in your mailbox.</p>
<LoginArea className="max-w-60" />
</div>
);
}
const handleToggle = async () => {
try {
const next = !isSubscribed;
await setSubscribed(next);
if (next) {
setJustSubscribed(true);
setTimeout(() => setJustSubscribed(false), 2500);
toast({
title: "You're subscribed! 🛹",
description: 'New drops will land in your notification bell under "News".',
});
} else {
toast({
title: 'Unsubscribed',
description: 'You can still read every issue here anytime.',
});
}
} catch (err) {
toast({
title: 'Something went wrong',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
if (isLoading) {
return (
<Button disabled className={cn('gap-2 h-11', className)}>
<Loader2 className="h-4 w-4 animate-spin" /> Checking…
</Button>
);
}
if (isSubscribed) {
return (
<Button
onClick={handleToggle}
disabled={isUpdating}
variant="outline"
className={cn(
'group gap-2 h-11 border-[#ccff00]/50 bg-[#ccff00]/10 font-black uppercase text-xs text-[#ccff00] hover:bg-[#ff0040]/10 hover:border-[#ff0040]/50 hover:text-[#ff0040]',
className,
)}
>
{isUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : justSubscribed ? (
<Check className="h-4 w-4" />
) : (
<>
<Check className="h-4 w-4 group-hover:hidden" />
<BellOff className="hidden h-4 w-4 group-hover:block" />
</>
)}
<span className="group-hover:hidden">Subscribed</span>
<span className="hidden group-hover:inline">Unsubscribe</span>
</Button>
);
}
return (
<Button
onClick={handleToggle}
disabled={isUpdating}
className={cn(
'gap-2 h-11 bg-[#ccff00] font-black uppercase text-xs text-black hover:bg-[#ccff00]/90',
className,
)}
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
Subscribe to the newsletter
</Button>
);
}

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart, Radio, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
@ -13,11 +14,14 @@ import { ActivityNotificationCard } from '@/components/ActivityNotificationCard'
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, type MemberVisibility, type SkateCrew } from '@/lib/types';
import { NEWSLETTER_KIND, SKATE_CREW_KIND, type MemberVisibility, type SkateCrew, type SkateNewsletterIssue } from '@/lib/types';
import { cn } from '@/lib/utils';
/**
@ -122,6 +126,56 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
);
}
/** A newsletter issue row in the bell's News tab. */
function NewsletterItem({
issue,
unread,
onNavigate,
}: {
issue: SkateNewsletterIssue;
unread: boolean;
onNavigate: () => void;
}) {
const naddr = nip19.naddrEncode({
kind: NEWSLETTER_KIND,
pubkey: issue.pubkey,
identifier: issue.identifier,
});
const date = new Date(issue.publishedAt * 1000).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
return (
<Link
to={`/${naddr}`}
onClick={onNavigate}
className={cn(
'group flex gap-3 rounded-xl border p-3 transition-all hover:border-[#ccff00]/50 hover:bg-white/[0.06]',
unread ? 'border-[#ccff00]/30 bg-[#ccff00]/[0.06]' : 'border-white/10 bg-white/[0.02]',
)}
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#ccff00]/15">
{issue.image ? (
<img src={issue.image} alt="" className="h-full w-full object-cover" />
) : (
<Radio className="h-4 w-4 text-[#ccff00]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-black text-white">{issue.title}</p>
{unread && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#ccff00]" />}
</div>
{issue.summary && <p className="line-clamp-2 text-xs text-gray-400">{issue.summary}</p>}
<p className="mt-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-gray-600">
{date} · Read <ArrowRight className="h-2.5 w-2.5" />
</p>
</div>
</Link>
);
}
function EmptyState({ label }: { label: string }) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center">
@ -151,6 +205,11 @@ export function NotificationBell({ className }: { className?: string }) {
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const { lastRead, markAllRead } = useNotificationsRead();
// Newsletter mailbox — only nags subscribers, but always browsable in the tab.
const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues();
const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead();
const { isSubscribed } = useNewsletterSubscription();
const crewTotal = useMemo(
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
[invites, listedCrews, joinRequests, collectiveRequests],
@ -162,13 +221,26 @@ export function NotificationBell({ className }: { className?: string }) {
[activity, lastRead],
);
// Crew items are always "actionable" so they always count toward the badge.
const badgeTotal = crewTotal + unreadActivity;
// Unread newsletter issues (only counts for subscribers).
const unreadNewsletter = useMemo(
() =>
isSubscribed
? newsletterIssues.filter((i) => i.publishedAt > newsletterLastRead).length
: 0,
[isSubscribed, newsletterIssues, newsletterLastRead],
);
// Opening the bell marks everything as read.
// Crew items are always "actionable" so they always count toward the badge.
const badgeTotal = crewTotal + unreadActivity + unreadNewsletter;
// Opening the bell marks social + crew as read (and newsletter, since the
// News tab is right there). Newsletter read state also clears on the /newsletter page.
useEffect(() => {
if (open) markAllRead();
}, [open, markAllRead]);
if (open) {
markAllRead();
markNewsletterRead();
}
}, [open, markAllRead, markNewsletterRead]);
const isLoading = crewLoading || activityLoading;
@ -207,7 +279,7 @@ export function NotificationBell({ className }: { className?: string }) {
</div>
<Tabs defaultValue="all" className="w-full">
<TabsList className="grid w-full grid-cols-3 rounded-none border-b border-white/10 bg-transparent p-0">
<TabsList className="grid w-full grid-cols-4 rounded-none border-b border-white/10 bg-transparent p-0">
<TabsTrigger
value="all"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
@ -221,6 +293,13 @@ export function NotificationBell({ className }: { className?: string }) {
>
<Heart className="mr-1 h-3 w-3" /> Social
</TabsTrigger>
<TabsTrigger
value="news"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ccff00] data-[state=active]:bg-transparent data-[state=active]:text-[#ccff00]"
>
<Radio className="mr-1 h-3 w-3" /> News
{unreadNewsletter > 0 && <span className="ml-1 text-[#ccff00]">{unreadNewsletter}</span>}
</TabsTrigger>
<TabsTrigger
value="crews"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
@ -238,8 +317,8 @@ export function NotificationBell({ className }: { className?: string }) {
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
</div>
)}
{!isLoading && crewTotal === 0 && activity.length === 0 && (
<EmptyState label="Zaps, reactions, replies and crew activity will show up here." />
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && (
<EmptyState label="Zaps, reactions, replies, newsletter drops and crew activity will show up here." />
)}
<div className="space-y-2.5">
{joinRequests.map(({ request, crew, endorsements }) => (
@ -254,6 +333,15 @@ export function NotificationBell({ className }: { className?: string }) {
{listedCrews.map((crew) => (
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
))}
{/* Latest newsletter drop surfaces in All when unread (subscribers). */}
{isSubscribed && newsletterIssues[0] && newsletterIssues[0].publishedAt > newsletterLastRead && (
<NewsletterItem
key={newsletterIssues[0].id}
issue={newsletterIssues[0]}
unread
onNavigate={() => setOpen(false)}
/>
)}
{activity.map((n) => (
<ActivityNotificationCard
key={n.id}
@ -287,6 +375,39 @@ export function NotificationBell({ className }: { className?: string }) {
</div>
</TabsContent>
{/* ─── NEWS (newsletter mailbox) ───────────────────────────── */}
<TabsContent value="news" className="m-0 p-3">
{newsletterLoading && newsletterIssues.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading the latest drops…
</div>
)}
{!newsletterLoading && newsletterIssues.length === 0 && (
<EmptyState label="SkateSpots newsletter drops will land here. Subscribe on the Newsletter page." />
)}
{!isSubscribed && newsletterIssues.length > 0 && (
<Link
to="/newsletter"
onClick={() => setOpen(false)}
className="mb-2.5 flex items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/[0.06] p-3 text-xs font-bold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10"
>
<Radio className="h-4 w-4 flex-shrink-0" />
<span className="flex-1">Subscribe to get new drops in your bell</span>
<ArrowRight className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
)}
<div className="space-y-2.5">
{newsletterIssues.map((issue) => (
<NewsletterItem
key={issue.id}
issue={issue}
unread={isSubscribed && issue.publishedAt > newsletterLastRead}
onNavigate={() => setOpen(false)}
/>
))}
</div>
</TabsContent>
{/* ─── CREWS ───────────────────────────────────────────────── */}
<TabsContent value="crews" className="m-0 p-3">
{crewLoading && crewTotal === 0 && (

View file

@ -0,0 +1,60 @@
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);
},
});
}

View file

@ -0,0 +1,20 @@
import { useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Tracks the timestamp (unix seconds) at which the user last read the newsletter
* mailbox. Any issue published after this is "unread" and contributes to the
* News badge. Stored per-pubkey so switching accounts keeps independent state.
*/
export function useNewsletterRead() {
const { user } = useCurrentUser();
const key = user ? `newsletter-last-read:${user.pubkey}` : 'newsletter-last-read:anon';
const [lastRead, setLastRead] = useLocalStorage<number>(key, 0);
const markNewsletterRead = useCallback(() => {
setLastRead(Math.floor(Date.now() / 1000));
}, [setLastRead]);
return { lastRead, markNewsletterRead };
}

View file

@ -0,0 +1,85 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
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 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'],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
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,
};
}

View file

@ -72,6 +72,70 @@ export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founde
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native.
export const COLLECTIVE_JOIN_REQUEST_KIND = 36387; // Addressable Lab-collective join-request — someone asks to join a Lab collective (studio/build team). Notifiable. SkateSpots-native.
export const NEWSLETTER_KIND = 30023; // NIP-23 long-form article — SkateSpots newsletter issues (admin-published, tagged `skatespotsnews`).
export const APP_DATA_KIND = 30078; // NIP-78 application-specific data (addressable). Used for the newsletter subscription marker.
/** `d`-tag identifier for the per-user newsletter subscription marker (kind 30078). */
export const NEWSLETTER_SUB_D = 'skatespots:newsletter:subscription';
/** `t`-tag every SkateSpots newsletter issue carries, so it's queryable at the relay level. */
export const NEWSLETTER_TAG = 'skatespotsnews';
/**
* A SkateSpots newsletter issue published as a NIP-23 long-form article (kind
* 30023) by an app admin. The `content` is Markdown; standard NIP-23 tags carry
* the metadata (title, summary, image, published_at).
*/
export interface SkateNewsletterIssue {
id: string;
pubkey: string;
/** NIP-23 `d` identifier — stable slug for the issue (e.g. "issue-6"). */
identifier: string;
title: string;
summary?: string;
image?: string;
/** Markdown body. */
content: string;
/** Publication time (NIP-23 `published_at` if present, else created_at). */
publishedAt: number;
createdAt: number;
event: NostrEvent;
}
/**
* Parse + validate a newsletter issue. Only trusts issues authored by a
* SkateSpots admin and tagged with NEWSLETTER_TAG — this is the trust boundary
* that stops anyone from spamming the community's newsletter mailbox.
*/
export function parseNewsletterIssue(event: NostrEvent): SkateNewsletterIssue | null {
if (event.kind !== NEWSLETTER_KIND) return null;
if (!isAdminPubkey(event.pubkey)) return null;
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
const identifier = tag('d');
const title = tag('title');
if (!identifier || !title) return null;
const isNewsletter = event.tags.some(([n, v]) => n === 't' && v === NEWSLETTER_TAG);
if (!isNewsletter) return null;
const publishedAtRaw = tag('published_at');
const publishedAt = publishedAtRaw ? parseInt(publishedAtRaw, 10) : event.created_at;
return {
id: event.id,
pubkey: event.pubkey,
identifier,
title,
summary: tag('summary'),
image: tag('image'),
content: event.content ?? '',
publishedAt: Number.isFinite(publishedAt) ? publishedAt : event.created_at,
createdAt: event.created_at,
event,
};
}
/**
* Lab provider entity type.

View file

@ -19,7 +19,8 @@ import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
import NotFound from './NotFound';
import {
MapPin,
@ -930,9 +931,15 @@ export function NIP19Page() {
// AI agent should implement event view here
return <NotFound />;
case 'naddr':
// AI agent should implement addressable event view here
case 'naddr': {
const addr = data as { kind: number; pubkey: string; identifier: string };
// Newsletter issues (NIP-23 long-form used by SkateSpots) get a reader.
if (addr.kind === NEWSLETTER_KIND) {
return <NewsletterIssueView pubkey={addr.pubkey} identifier={addr.identifier} />;
}
// Other addressable kinds aren't rendered here yet.
return <NotFound />;
}
default:
return <NotFound />;

View file

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -25,6 +26,11 @@ import { useGrowthStats } from '@/hooks/useGrowthStats';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { NewsletterComposer } from '@/components/NewsletterComposer';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
@ -1064,6 +1070,75 @@ function IssueArticle({ issue }: { issue: NewsletterIssue }) {
);
}
// ─── Live issues (published to Nostr as NIP-23 articles) ─────────────────────
function LiveIssueCard({ issue }: { issue: SkateNewsletterIssue }) {
const naddr = nip19.naddrEncode({
kind: NEWSLETTER_KIND,
pubkey: issue.pubkey,
identifier: issue.identifier,
});
const date = new Date(issue.publishedAt * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<Link
to={`/${naddr}`}
className="group block overflow-hidden rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] transition-all hover:border-[#ccff00]/50 hover:bg-white/[0.06]"
>
{issue.image && (
<div className="aspect-video w-full overflow-hidden">
<img
src={issue.image}
alt={issue.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
<div className="p-5">
<p className="mb-1.5 text-[10px] font-black uppercase tracking-widest text-gray-500">{date}</p>
<h3 className="mb-1 text-lg font-black uppercase leading-tight text-white group-hover:text-[#ccff00]">
{issue.title}
</h3>
{issue.summary && <p className="line-clamp-2 text-sm text-gray-400">{issue.summary}</p>}
<span className="mt-3 inline-flex items-center gap-1.5 text-xs font-bold text-[#ccff00]">
Read the full issue <ArrowRight className="h-3.5 w-3.5" />
</span>
</div>
</Link>
);
}
function LiveIssuesSection() {
const { data: issues, isLoading } = useNewsletterIssues();
if (isLoading) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-56 w-full rounded-2xl" />
<Skeleton className="h-56 w-full rounded-2xl" />
</div>
);
}
if (!issues || issues.length === 0) return null;
return (
<section className="mb-16">
<SectionHeader emoji="📡" label="Latest Drops · Live from Nostr" color="#ccff00" />
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{issues.map((issue) => (
<LiveIssueCard key={issue.id} issue={issue} />
))}
</div>
</section>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function NewsletterPage() {
@ -1072,12 +1147,18 @@ export default function NewsletterPage() {
description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.',
});
// Opening the newsletter clears the "News" unread badge in the bell.
const { markNewsletterRead } = useNewsletterRead();
useEffect(() => {
markNewsletterRead();
}, [markNewsletterRead]);
return (
<Layout>
<div className="container max-w-2xl px-4 py-8 md:py-14 pb-24 md:pb-14">
{/* ── Masthead ── */}
<div className="mb-12 text-center">
<div className="mb-10 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-5">
<Radio className="h-3 w-3" />
Weekly Drops
@ -1091,11 +1172,26 @@ export default function NewsletterPage() {
</span>
<span className="text-white">SPOTS</span>
</h1>
<p className="text-gray-500 text-xs uppercase tracking-widest">
<p className="text-gray-500 text-xs uppercase tracking-widest mb-6">
A weekly newsletter · shipped whenever the f*** we want
</p>
{/* Subscribe — routes new drops into the notification bell */}
<div className="flex flex-col items-center gap-2">
<div className="flex flex-wrap items-center justify-center gap-2">
<NewsletterSubscribeButton />
<NewsletterComposer />
</div>
<p className="max-w-xs text-[11px] leading-snug text-gray-600">
Free. Powered by Nostr — your subscription lives on your keys and follows you
across devices. New drops land in your notification bell.
</p>
</div>
</div>
{/* ── Live issues published to Nostr ── */}
<LiveIssuesSection />
{/* ── All issues ── */}
<div className="space-y-20">
{NEWSLETTER_ISSUES.map((issue) => (