SkateSpots/src/components/NewsletterIssueView.tsx
SkateSpots 565fdb9211 fix(newsletter): render _underscore italics_ in issue bodies
issueToMarkdown wraps each issue's vibe in _underscores_, but the in-app
markdown renderer only understood *asterisk* emphasis — so every published
archive issue showed LITERAL underscores around its intro line (both in our
reader and here). Added the underscore form to the tokenizer.

Verified on the live issue-5 naddr: the vibe now renders as <em> with no
literal underscores.
2026-09-27 20:34:31 +07:00

215 lines
8.5 KiB
TypeScript

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*, _italic_ (underscore form — issueToMarkdown
// wraps the vibe in underscores, which used to render as LITERAL underscores
// in this reader), [label](url) and 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(<em key={i++} className="italic">{m[6]}</em>);
} else if (m[7]) {
nodes.push(
<a key={i++} href={m[9]} target="_blank" rel="noopener noreferrer" className="text-[#ccff00] underline underline-offset-2 hover:opacity-80">
{m[8]}
</a>,
);
} else if (m[10]) {
nodes.push(
<a key={i++} href={m[10]} target="_blank" rel="noopener noreferrer" className="text-[#ccff00] underline underline-offset-2 hover:opacity-80 break-all">
{m[10]}
</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 (
<>
<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>
</>
);
}
// 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 (
<>
<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">
<ImageWithFallback 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>
</>
);
}