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.
218 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|