import { useMemo } from 'react'; import { ImageWithFallback } from '@/components/ImageWithFallback'; 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 { 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({m[2]}); } else if (m[3]) { nodes.push({m[4]}); } else if (m[5]) { nodes.push( {m[6]} , ); } else if (m[8]) { nodes.push( {m[8]} , ); } 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( , ); 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(

{renderInline(line.slice(4))}

); } else if (line.startsWith('## ')) { blocks.push(

{renderInline(line.slice(3))}

); } else if (line.startsWith('# ')) { blocks.push(

{renderInline(line.slice(2))}

); } else { blocks.push(

{renderInline(line)}

); } } 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 => { 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 ( <>
); } // Not a valid / admin-authored newsletter issue → 404 (keeps the trust boundary). if (!issue) return ; 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 ( <>
All issues
SkateSpots Newsletter
{issue.image && (
)}

{issue.title}

{date} {authorName.slice(0, 2).toUpperCase()} {authorName}
{issue.summary && (

{issue.summary}

)}
{body}
{/* Footer CTA: zap the issue + subscribe */}

Dig the drop? Zap it and never miss the next one.

); }