import { useState } from 'react'; import { useNostr } from '@nostrify/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 { useSkateRelays } from '@/hooks/useSkateRelays'; 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 { nostr } = useNostr(); const { getWriteRelays } = useSkateRelays(); const { toast } = useToast(); const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); 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; // A manual slug is used verbatim (lets an admin intentionally EDIT/replace a // specific existing issue). When left blank we must NOT use the bare // title-slug: kind 30023 is addressable, so two issues that slugify the same // (e.g. a recurring "Weekly Roundup", or "Update!" vs "Update?") would share a // `d` and the newer one would silently OVERWRITE the older. So an auto-slug // gets a short date suffix to make each published issue its own coordinate. const manualSlug = slug.trim(); const canPublish = title.trim().length >= 2 && body.trim().length >= 10; const handlePublish = async () => { if (!canPublish || !user) return; const now = Math.floor(Date.now() / 1000); // YYYYMMDD suffix (UTC) keeps auto-slugs readable and unique per day; a // second same-title issue on the same day also gets a time component. const d = new Date(now * 1000); const dateSuffix = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`; const autoSlug = `${slugify(title) || 'issue'}-${dateSuffix}-${now.toString(36).slice(-4)}`; const effectiveSlug = manualSlug || autoSlug; setIsPending(true); try { const event = await user.signer.signEvent({ kind: NEWSLETTER_KIND, content: body, created_at: now, 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()}`], ], }); // Publish to the COMMUNITY relay (+ the admin's own relays), NOT just the // default pool. Readers — including logged-out visitors — only query the // community relay, so an issue written to the admin's personal relays // alone would be invisible to almost everyone. const results = await Promise.allSettled( getWriteRelays().map((url) => nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }), ), ); if (results.every((r) => r.status === 'rejected')) { throw new Error('Could not reach any relay — check your connection and try again.'); } 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', }); } finally { setIsPending(false); } }; return ( Publish a newsletter issue Published as a NIP-23 article (kind 30023) on the community relay. Subscribers get it in their notification bell. Body supports Markdown.
setTitle(e.target.value)} placeholder="SkateSpots Drop #006" maxLength={120} className="mt-1" />
setSlug(e.target.value)} placeholder={slugify(title) || 'issue-6'} maxLength={60} className="mt-1 font-mono text-sm" />

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

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