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:
parent
cdf8cee3a6
commit
08f74d7991
11 changed files with 1031 additions and 16 deletions
61
NIP.md
61
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": "<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.
|
||||
|
|
|
|||
187
src/components/NewsletterComposer.tsx
Normal file
187
src/components/NewsletterComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/components/NewsletterIssueView.tsx
Normal file
210
src/components/NewsletterIssueView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/NewsletterSubscribeButton.tsx
Normal file
104
src/components/NewsletterSubscribeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
60
src/hooks/useNewsletterIssues.ts
Normal file
60
src/hooks/useNewsletterIssues.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
20
src/hooks/useNewsletterRead.ts
Normal file
20
src/hooks/useNewsletterRead.ts
Normal 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 };
|
||||
}
|
||||
85
src/hooks/useNewsletterSubscription.ts
Normal file
85
src/hooks/useNewsletterSubscription.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
Loading…
Reference in a new issue