SkateSpots/src/components/NewsletterComposer.tsx
SkateSpots e98c996945 fix(newsletter): infinite render loop + issues published to the wrong relays
Audit of the newsletter section found two real bugs.

1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
   depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
   -> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
   markAllRead changed every render -> the page effect (deps include it) re-ran
   after every render -> the mark set a NEW state object -> loop. The
   notification bell looped the same way while open.
   - useLocalStorage: setter is now referentially stable (ref + functional set).
   - useSyncedAppData: returned object is memoized.
   - useReadState: apply reads synced/interactive from refs (stable); marks are
     idempotent (same object when already marked) so React bails.
   - NewsletterPage marks read once per visit (mount-once ref guard).

2) Issues + subscription markers were published via the default pool (the
   author's own NIP-65 relays) but READ via useSkateRelays (community relay +
   reader's relays; logged-out = community relay only), so an admin without
   goskatespots in their list would publish drops nobody could see. Composer,
   backfill panel and the subscribe hook now publish to getWriteRelays() and
   fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
   (no schema change).

Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
2026-09-27 17:40:02 +07:00

218 lines
8.3 KiB
TypeScript

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 (
<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>
);
}