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.
215 lines
8.5 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|