Newsletter: subscriber count + admin archive backfill to Nostr
Two follow-ups to the Nostr-native newsletter (skipped auto-DM by design — too spammy/fragile for now; the bell mailbox already covers the inbox feel). Subscriber count (social proof): - useNewsletterSubscriberCount tallies public NIP-78 subscription markers (deduped per author, counting current "true" state). - Shown under the subscribe CTA as "Join N+ skaters getting the drops", hidden until it's meaningful (>=3) and rounded once it grows. Archive backfill (consistency without losing the rich page): - lib/newsletterArticle.ts converts a static NEWSLETTER_ISSUES entry into a NIP-23 (kind 30023) article — structured sections rendered to Markdown, stable slug `issue-N`, published_at from the issue date. - NewsletterBackfillPanel (admin-only) diffs the static archive against what's already on-relay and publishes only the missing issues; safe to re-run, reports partial failures. The rich static page rendering is untouched. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
08f74d7991
commit
dc410106b3
4 changed files with 273 additions and 0 deletions
108
src/components/NewsletterBackfillPanel.tsx
Normal file
108
src/components/NewsletterBackfillPanel.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, Loader2, Check, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsletterArticle';
|
||||
|
||||
/**
|
||||
* Admin-only one-time backfill: publishes the static archive issues
|
||||
* (NEWSLETTER_ISSUES, passed in as a prop to avoid an import cycle with the
|
||||
* page) to Nostr as NIP-23 articles. Skips any issue whose slug already exists
|
||||
* on-relay, so it's safe to run repeatedly. The rich static page rendering is
|
||||
* untouched — this just mirrors the archive onto Nostr for subscribers/readers.
|
||||
*/
|
||||
export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[] }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publish } = useNostrPublish();
|
||||
const { data: liveIssues } = useNewsletterIssues();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
if (!isAdminPubkey(user?.pubkey) || !issues || issues.length === 0) return null;
|
||||
|
||||
// Which static issues aren't on Nostr yet? (match by slug)
|
||||
const liveSlugs = new Set((liveIssues ?? []).map((i) => i.identifier));
|
||||
const missing = issues.filter((i) => !liveSlugs.has(issueSlug(i.number)));
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setRunning(true);
|
||||
let published = 0;
|
||||
const failures: number[] = [];
|
||||
// Publish oldest → newest so numbering reads naturally on relays.
|
||||
const ordered = [...missing].sort((a, b) => a.number - b.number);
|
||||
for (const issue of ordered) {
|
||||
try {
|
||||
await publish(buildIssueArticle(issue));
|
||||
published++;
|
||||
} catch {
|
||||
failures.push(issue.number);
|
||||
}
|
||||
}
|
||||
setRunning(false);
|
||||
setDone(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] });
|
||||
|
||||
if (failures.length === 0) {
|
||||
toast({
|
||||
title: `Backfilled ${published} issue${published === 1 ? '' : 's'} 🛹`,
|
||||
description: 'The archive now lives on Nostr for subscribers and readers.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Published ${published}, ${failures.length} failed`,
|
||||
description: `Retry to finish: #${failures.map((n) => String(n).padStart(3, '0')).join(', #')}.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setDone(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (missing.length === 0) {
|
||||
return (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-green-500/70">
|
||||
<Check className="h-3 w-3" /> Archive synced to Nostr
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full max-w-sm rounded-xl border border-yellow-500/30 bg-yellow-500/[0.06] p-3 text-left">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-yellow-200">
|
||||
{missing.length} archive issue{missing.length === 1 ? '' : 's'} not on Nostr yet
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] leading-snug text-gray-400">
|
||||
Publish {missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ')} as
|
||||
NIP-23 articles so subscribers and long-form readers can see the full archive. The page
|
||||
keeps its rich layout regardless.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBackfill}
|
||||
disabled={running || done}
|
||||
className="mt-2 h-8 gap-1.5 bg-yellow-500 font-black uppercase text-[11px] text-black hover:bg-yellow-400"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : done ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{running ? 'Publishing…' : done ? 'Done' : `Backfill ${missing.length} to Nostr`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/hooks/useNewsletterSubscriberCount.ts
Normal file
46
src/hooks/useNewsletterSubscriberCount.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { APP_DATA_KIND, NEWSLETTER_SUB_D } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* A best-effort community tally of newsletter subscribers.
|
||||
*
|
||||
* Each subscriber owns a public NIP-78 marker (kind 30078, d=NEWSLETTER_SUB_D)
|
||||
* with a `subscribed` boolean tag. We query those markers and count the ones
|
||||
* currently set to "true", deduped by author (one vote per pubkey).
|
||||
*
|
||||
* This is intentionally approximate — relays only return what they hold — so we
|
||||
* present it as social proof ("N+ skaters"), not an exact figure.
|
||||
*/
|
||||
export function useNewsletterSubscriberCount() {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['newsletter-subscriber-count', getQueryKeySuffix()],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
queryFn: async (c): Promise<number> => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [APP_DATA_KIND], '#d': [NEWSLETTER_SUB_D], limit: 1000 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Keep the newest marker per author, then count the "true" ones.
|
||||
const latestByAuthor = new Map<string, { subscribed: boolean; createdAt: number }>();
|
||||
for (const ev of events) {
|
||||
const subscribed = ev.tags.find(([n]) => n === 'subscribed')?.[1] === 'true';
|
||||
const existing = latestByAuthor.get(ev.pubkey);
|
||||
if (!existing || ev.created_at > existing.createdAt) {
|
||||
latestByAuthor.set(ev.pubkey, { subscribed, createdAt: ev.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const { subscribed } of latestByAuthor.values()) {
|
||||
if (subscribed) count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
});
|
||||
}
|
||||
101
src/lib/newsletterArticle.ts
Normal file
101
src/lib/newsletterArticle.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* The subset of a static newsletter issue we need to turn it into a NIP-23
|
||||
* long-form article (kind 30023). Kept structural (not importing the page) to
|
||||
* avoid a page → lib → page import cycle.
|
||||
*/
|
||||
export interface StaticIssueLike {
|
||||
number: number;
|
||||
date: string; // ISO "YYYY-MM-DD"
|
||||
vibe: string;
|
||||
updates: { title: string; tag: string; body: string }[];
|
||||
spotOfTheWeek?: {
|
||||
title: string;
|
||||
location: string;
|
||||
why: string;
|
||||
mapperName?: string;
|
||||
image?: string;
|
||||
href?: string;
|
||||
};
|
||||
communityNote: string;
|
||||
}
|
||||
|
||||
/** Stable addressable identifier for a backfilled archive issue. */
|
||||
export function issueSlug(number: number): string {
|
||||
return `issue-${number}`;
|
||||
}
|
||||
|
||||
/** Human title for an issue, e.g. "SkateSpots Drop #005". */
|
||||
export function issueTitle(number: number): string {
|
||||
return `SkateSpots Drop #${String(number).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** Render a static issue's structured sections into a Markdown article body. */
|
||||
export function issueToMarkdown(issue: StaticIssueLike): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`_${issue.vibe}_`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## What shipped');
|
||||
lines.push('');
|
||||
for (const u of issue.updates) {
|
||||
lines.push(`### ${u.title}`);
|
||||
lines.push(`**${u.tag}**`);
|
||||
lines.push('');
|
||||
lines.push(u.body);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (issue.spotOfTheWeek) {
|
||||
const s = issue.spotOfTheWeek;
|
||||
lines.push('## Spot of the Week');
|
||||
lines.push('');
|
||||
lines.push(`**${s.title}** — ${s.location}`);
|
||||
lines.push('');
|
||||
lines.push(s.why);
|
||||
if (s.mapperName) {
|
||||
lines.push('');
|
||||
lines.push(`_Mapped by ${s.mapperName}_`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('## Community');
|
||||
lines.push('');
|
||||
lines.push(issue.communityNote);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push('Read every drop at https://skate-spots.org/#/newsletter');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** A one-line summary for the article's NIP-23 `summary` tag. */
|
||||
export function issueSummary(issue: StaticIssueLike): string {
|
||||
return issue.vibe.length > 180 ? `${issue.vibe.slice(0, 177)}…` : issue.vibe;
|
||||
}
|
||||
|
||||
/** Build the full (unsigned) kind-30023 event template for a static issue. */
|
||||
export function buildIssueArticle(issue: StaticIssueLike) {
|
||||
const publishedAt = Math.floor(new Date(issue.date + 'T12:00:00Z').getTime() / 1000);
|
||||
const image = issue.spotOfTheWeek?.image;
|
||||
return {
|
||||
kind: NEWSLETTER_KIND,
|
||||
content: issueToMarkdown(issue),
|
||||
tags: [
|
||||
['d', issueSlug(issue.number)],
|
||||
['title', issueTitle(issue.number)],
|
||||
['summary', issueSummary(issue)],
|
||||
...(image ? [['image', image]] : []),
|
||||
['published_at', String(Number.isFinite(publishedAt) ? publishedAt : Math.floor(Date.now() / 1000))],
|
||||
['t', NEWSLETTER_TAG],
|
||||
['t', 'skatespots'],
|
||||
['t', 'newsletter'],
|
||||
['t', 'skateboarding'],
|
||||
['alt', `SkateSpots newsletter issue: ${issueTitle(issue.number)}`],
|
||||
] as string[][],
|
||||
};
|
||||
}
|
||||
|
|
@ -28,7 +28,9 @@ import { genUserName } from '@/lib/genUserName';
|
|||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
|
||||
import { NewsletterComposer } from '@/components/NewsletterComposer';
|
||||
import { NewsletterBackfillPanel } from '@/components/NewsletterBackfillPanel';
|
||||
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||
import { useNewsletterSubscriberCount } from '@/hooks/useNewsletterSubscriberCount';
|
||||
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
|
||||
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
|
||||
|
||||
|
|
@ -1113,6 +1115,19 @@ function LiveIssueCard({ issue }: { issue: SkateNewsletterIssue }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Social-proof subscriber tally shown under the subscribe CTA. */
|
||||
function SubscriberCount() {
|
||||
const { data: count, isLoading } = useNewsletterSubscriberCount();
|
||||
if (isLoading || !count || count < 3) return null; // hide until it's meaningful
|
||||
const rounded = count >= 100 ? `${Math.floor(count / 50) * 50}+` : `${count}`;
|
||||
return (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] font-bold uppercase tracking-widest text-[#ccff00]">
|
||||
<Users className="h-3 w-3" />
|
||||
Join {rounded} skaters getting the drops
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIssuesSection() {
|
||||
const { data: issues, isLoading } = useNewsletterIssues();
|
||||
|
||||
|
|
@ -1182,10 +1197,13 @@ export default function NewsletterPage() {
|
|||
<NewsletterSubscribeButton />
|
||||
<NewsletterComposer />
|
||||
</div>
|
||||
<SubscriberCount />
|
||||
<p className="max-w-xs text-[11px] leading-snug text-gray-600">
|
||||
Free. Powered by Nostr — your subscription lives on your keys and follows you
|
||||
across devices. New drops land in your notification bell.
|
||||
</p>
|
||||
{/* Admin-only: publish the static archive issues to Nostr. */}
|
||||
<NewsletterBackfillPanel issues={NEWSLETTER_ISSUES} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue