SkateSpots/src/hooks/useNewsletterIssues.ts

60 lines
2 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
NEWSLETTER_KIND,
NEWSLETTER_TAG,
parseNewsletterIssue,
type SkateNewsletterIssue,
} from '@/lib/types';
/**
* Live SkateSpots newsletter issues, published by app admins as NIP-23
* long-form articles (kind 30023) tagged `skatespotsnews`.
*
* Trust: the query is scoped to `authors: ADMIN_PUBKEYS` at the relay level AND
* re-validated client-side via `parseNewsletterIssue` (which re-checks the
* admin allowlist + the newsletter tag) so a rogue relay can't inject issues.
*
* Sorted newest-first by `published_at`.
*/
export function useNewsletterIssues(limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['newsletter-issues', limit, getQueryKeySuffix()],
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
queryFn: async (c): Promise<SkateNewsletterIssue[]> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[
{
kinds: [NEWSLETTER_KIND],
authors: ADMIN_PUBKEYS,
'#t': [NEWSLETTER_TAG],
limit,
},
],
{ signal },
);
const issues: SkateNewsletterIssue[] = [];
for (const ev of events) {
const parsed = parseNewsletterIssue(ev);
if (parsed) issues.push(parsed);
}
// Dedupe by addressable coordinate (pubkey:d) keeping the newest.
const byCoord = new Map<string, SkateNewsletterIssue>();
for (const issue of issues) {
const key = `${issue.pubkey}:${issue.identifier}`;
const existing = byCoord.get(key);
if (!existing || issue.createdAt > existing.createdAt) byCoord.set(key, issue);
}
return Array.from(byCoord.values()).sort((a, b) => b.publishedAt - a.publishedAt);
},
});
}