From dc410106b32ab14f0e0f96038c939e1113cb413a Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 19:21:09 +0700 Subject: [PATCH] Newsletter: subscriber count + admin archive backfill to Nostr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/NewsletterBackfillPanel.tsx | 108 +++++++++++++++++++++ src/hooks/useNewsletterSubscriberCount.ts | 46 +++++++++ src/lib/newsletterArticle.ts | 101 +++++++++++++++++++ src/pages/NewsletterPage.tsx | 18 ++++ 4 files changed, 273 insertions(+) create mode 100644 src/components/NewsletterBackfillPanel.tsx create mode 100644 src/hooks/useNewsletterSubscriberCount.ts create mode 100644 src/lib/newsletterArticle.ts diff --git a/src/components/NewsletterBackfillPanel.tsx b/src/components/NewsletterBackfillPanel.tsx new file mode 100644 index 0000000..aa8b028 --- /dev/null +++ b/src/components/NewsletterBackfillPanel.tsx @@ -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 ( +

+ Archive synced to Nostr +

+ ); + } + + return ( +
+
+ +
+

+ {missing.length} archive issue{missing.length === 1 ? '' : 's'} not on Nostr yet +

+

+ 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. +

+ +
+
+
+ ); +} diff --git a/src/hooks/useNewsletterSubscriberCount.ts b/src/hooks/useNewsletterSubscriberCount.ts new file mode 100644 index 0000000..cda8f06 --- /dev/null +++ b/src/hooks/useNewsletterSubscriberCount.ts @@ -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 => { + 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(); + 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; + }, + }); +} diff --git a/src/lib/newsletterArticle.ts b/src/lib/newsletterArticle.ts new file mode 100644 index 0000000..664d43f --- /dev/null +++ b/src/lib/newsletterArticle.ts @@ -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[][], + }; +} diff --git a/src/pages/NewsletterPage.tsx b/src/pages/NewsletterPage.tsx index ccf7ed6..69d3e5e 100644 --- a/src/pages/NewsletterPage.tsx +++ b/src/pages/NewsletterPage.tsx @@ -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 ( +

+ + Join {rounded} skaters getting the drops +

+ ); +} + function LiveIssuesSection() { const { data: issues, isLoading } = useNewsletterIssues(); @@ -1182,10 +1197,13 @@ export default function NewsletterPage() { +

Free. Powered by Nostr — your subscription lives on your keys and follows you across devices. New drops land in your notification bell.

+ {/* Admin-only: publish the static archive issues to Nostr. */} +