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.
168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Link, useLocation } from 'react-router-dom';
|
|
import { X, Radio, Pencil } from 'lucide-react';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
import { NEWSLETTER_ISSUES } from '@/lib/newsletterIssues';
|
|
import { isAdminPubkey } from '@/lib/admins';
|
|
|
|
/**
|
|
* How many days the "Latest Drop" pill stays visible after a new issue.
|
|
* After this window it auto-hides until the next issue drops.
|
|
*/
|
|
const PILL_VISIBLE_DAYS = 7;
|
|
|
|
/**
|
|
* Pre-filled Shakespeare chat URL that opens ready to write the next newsletter.
|
|
* When you click it on a Monday, it takes you straight into the AI with the
|
|
* prompt already typed — just hit send.
|
|
*/
|
|
const SHAKESPEARE_PROMPT = encodeURIComponent(
|
|
'Write this week\'s SkateSpots newsletter issue. Read the git log for the past 7 days, summarize all changes in plain skater language, pick a spot of the week if any new ones were submitted, and format it following the same structure as Issue #001. Keep it short, punchy, no corporate tone.'
|
|
);
|
|
const WRITE_NEWSLETTER_URL = `https://shakespeare.diy/project/skatespots?prompt=${SHAKESPEARE_PROMPT}`;
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function isMonday(): boolean {
|
|
return new Date().getDay() === 1;
|
|
}
|
|
|
|
function daysSinceIssue(isoDate: string): number {
|
|
const issued = new Date(isoDate).getTime();
|
|
const now = Date.now();
|
|
return Math.floor((now - issued) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function formatIssueNumber(n: number): string {
|
|
return `#${String(n).padStart(3, '0')}`;
|
|
}
|
|
|
|
// ─── Floating "Latest Drop" pill ─────────────────────────────────────────────
|
|
|
|
function LatestDropPill() {
|
|
const location = useLocation();
|
|
const latest = NEWSLETTER_ISSUES[0];
|
|
const [dismissed, setDismissed] = useLocalStorage<number>(
|
|
'newsletter-pill-dismissed-issue',
|
|
0
|
|
);
|
|
// Guard: no archive yet ⇒ nothing to promote.
|
|
if (!latest) return null;
|
|
const age = daysSinceIssue(latest.date);
|
|
|
|
// Hide on newsletter page, map page (to avoid conflicts with map UI), if too old, or if already dismissed
|
|
const isMapPage = location.pathname === '/' || location.pathname === '/map';
|
|
if (
|
|
location.pathname === '/newsletter' ||
|
|
isMapPage ||
|
|
age > PILL_VISIBLE_DAYS ||
|
|
dismissed === latest.number
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="fixed bottom-[calc(4.75rem+env(safe-area-inset-bottom,0px))] md:bottom-6 right-3 z-40 flex items-center gap-1.5 animate-in slide-in-from-bottom-4 duration-300 max-w-[calc(100vw-1.5rem)]">
|
|
<Link
|
|
to="/newsletter"
|
|
className="flex items-center gap-2 pl-3 pr-3 py-2 rounded-full bg-black/98 border border-[#ccff00]/50 shadow-lg shadow-[#ccff00]/10 hover:border-[#ccff00] transition-all active:scale-95 touch-manipulation"
|
|
>
|
|
<Radio className="h-3.5 w-3.5 text-[#ccff00] animate-pulse flex-shrink-0" />
|
|
<span className="text-xs font-black text-[#ccff00] uppercase tracking-wide whitespace-nowrap">
|
|
Drop {formatIssueNumber(latest.number)} is out
|
|
</span>
|
|
<span className="text-[10px] text-gray-500 hidden sm:inline">
|
|
· {age === 0 ? 'Today' : age === 1 ? 'Yesterday' : `${age}d ago`}
|
|
</span>
|
|
</Link>
|
|
<button
|
|
onClick={() => setDismissed(latest.number)}
|
|
className="flex items-center justify-center w-7 h-7 flex-shrink-0 rounded-full bg-black/98 border border-white/10 text-gray-500 hover:text-white hover:border-white/30 transition-colors touch-manipulation active:scale-95"
|
|
aria-label="Dismiss"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Monday admin reminder ────────────────────────────────────────────────────
|
|
|
|
function MondayAdminReminder() {
|
|
const { user } = useCurrentUser();
|
|
const location = useLocation();
|
|
const [dismissed, setDismissed] = useLocalStorage<string>(
|
|
'newsletter-monday-dismissed',
|
|
''
|
|
);
|
|
|
|
// Today's date key — so it only shows once per Monday, not every page load
|
|
const todayKey = new Date().toISOString().slice(0, 10);
|
|
const isAdmin = isAdminPubkey(user?.pubkey);
|
|
const alreadyDismissedToday = dismissed === todayKey;
|
|
|
|
if (
|
|
!isAdmin ||
|
|
!isMonday() ||
|
|
alreadyDismissedToday ||
|
|
NEWSLETTER_ISSUES.length === 0 ||
|
|
location.pathname === '/newsletter'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="fixed top-20 md:top-20 right-4 z-50 max-w-xs animate-in slide-in-from-right-4 duration-300">
|
|
<div className="bg-black/98 border-2 border-[#ccff00]/60 rounded-2xl shadow-2xl shadow-[#ccff00]/10 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-8 h-8 rounded-xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0">
|
|
<Pencil className="h-4 w-4 text-[#ccff00]" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[#ccff00] font-black text-xs uppercase tracking-wide mb-1">
|
|
It's Monday
|
|
</p>
|
|
<p className="text-gray-300 text-xs leading-relaxed mb-3">
|
|
Time to drop the weekly newsletter. One click and the AI writes it from the git log.
|
|
</p>
|
|
<a
|
|
href={WRITE_NEWSLETTER_URL}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center gap-2 w-full py-2 rounded-xl bg-[#ccff00] text-black text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/90 transition-colors"
|
|
onClick={() => setDismissed(todayKey)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Write Drop #{String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}
|
|
</a>
|
|
</div>
|
|
<button
|
|
onClick={() => setDismissed(todayKey)}
|
|
className="flex-shrink-0 text-gray-600 hover:text-gray-400 transition-colors"
|
|
aria-label="Dismiss reminder"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Combined overlay export ──────────────────────────────────────────────────
|
|
|
|
export function NewsletterOverlay() {
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
// Avoid SSR hydration mismatch — only render client-side
|
|
useEffect(() => { setMounted(true); }, []);
|
|
if (!mounted) return null;
|
|
|
|
return (
|
|
<>
|
|
<LatestDropPill />
|
|
<MondayAdminReminder />
|
|
</>
|
|
);
|
|
}
|