import { useCallback, useEffect, useState } from 'react' import relayAdmin from '@/services/relay-admin.service' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { toast } from 'sonner' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface TrustedEntry { pubkey: string note?: string } interface BlacklistedEntry { pubkey: string reason?: string } interface UnclassifiedEntry { pubkey: string event_count: number } interface SpamEntry { event_id: string pubkey: string reason?: string } interface BlockedIP { ip: string reason?: string expires_at?: string } interface UserEvent { id: string kind: number content: string created_at: number } type Tab = 'trusted' | 'blacklist' | 'unclassified' | 'spam' | 'ips' | 'settings' type UserCategory = 'trusted' | 'blacklisted' | 'unclassified' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatPubkey(pk: string): string { if (!pk || pk.length < 16) return pk return `${pk.slice(0, 8)}...${pk.slice(-8)}` } function formatDate(ts: number | string | undefined): string { if (!ts) return '' const n = typeof ts === 'string' ? Date.parse(ts) : ts return new Date(n).toLocaleString() } const KIND_NAMES: Record = { 0: 'Metadata', 1: 'Text Note', 3: 'Follow List', 4: 'Encrypted DM', 6: 'Repost', 7: 'Reaction', 14: 'Chat Message', 1063: 'File Metadata', 10002: 'Relay List', 30023: 'Long-form', 30078: 'App Data', } function kindName(k: number): string { return KIND_NAMES[k] ?? `Kind ${k}` } function truncateContent(c: string, maxLines = 6): string { if (!c) return '' const lines = c.split('\n') if (lines.length <= maxLines && c.length <= maxLines * 100) return c let t = lines.slice(0, maxLines).join('\n') if (t.length > maxLines * 100) t = t.substring(0, maxLines * 100) return t } function isContentTruncated(c: string, maxLines = 6): boolean { if (!c) return false const lines = c.split('\n') return lines.length > maxLines || c.length > maxLines * 100 } // Wrapper that extracts .result from NIP-86 JSON-RPC response async function nip86(method: string, params: unknown[] = []): Promise { const res = await relayAdmin.nip86Request(method, params) if (res.error) throw new Error(String(res.error)) return res.result } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function TabButton({ active, onClick, children, }: { active: boolean onClick: () => void children: React.ReactNode }) { return ( ) } // --------------------------------------------------------------------------- // User Detail Panel // --------------------------------------------------------------------------- function UserDetail({ pubkey, category, onClose, onChanged, }: { pubkey: string category: UserCategory onClose: () => void onChanged: () => void }) { const [events, setEvents] = useState([]) const [total, setTotal] = useState(0) const [loadingEvents, setLoadingEvents] = useState(false) const [expanded, setExpanded] = useState>({}) const [busy, setBusy] = useState(false) const loadEvents = useCallback( async (offset: number) => { if (loadingEvents) return setLoadingEvents(true) try { const res = (await nip86('geteventsforpubkey', [pubkey, 100, offset])) as { events?: UserEvent[] total?: number } | null if (res) { if (offset === 0) { setEvents(res.events ?? []) } else { setEvents((prev) => [...prev, ...(res.events ?? [])]) } setTotal(res.total ?? 0) } } catch (e) { toast.error(`Failed to load events: ${e instanceof Error ? e.message : String(e)}`) } finally { setLoadingEvents(false) } }, [pubkey, loadingEvents] ) useEffect(() => { loadEvents(0) // eslint-disable-next-line react-hooks/exhaustive-deps }, [pubkey]) const act = async (method: string, params: unknown[], msg: string) => { setBusy(true) try { await nip86(method, params) toast.success(msg) onChanged() onClose() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } finally { setBusy(false) } } const deleteAll = async () => { if (!confirm(`Delete ALL ${total} events from this user? This cannot be undone.`)) return setBusy(true) try { const res = (await nip86('deleteeventsforpubkey', [pubkey])) as { deleted?: number } | null toast.success(`Deleted ${res?.deleted ?? 0} events`) setEvents([]) setTotal(0) } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } finally { setBusy(false) } } return (
{/* Header */}
User Events {formatPubkey(pubkey)} {total} events
{category === 'trusted' && ( <> )} {category === 'blacklisted' && ( <> )} {category === 'unclassified' && ( <> )}
{/* Events */}
{loadingEvents && events.length === 0 ? (

Loading events...

) : events.length === 0 ? (

No events found.

) : ( events.map((ev) => { const isExp = !!expanded[ev.id] const isTrunc = isContentTruncated(ev.content) return (
{kindName(ev.kind)} {formatPubkey(ev.id)} {formatDate(ev.created_at * 1000)}
                  {isExp || !isTrunc
                    ? ev.content || '(empty)'
                    : `${truncateContent(ev.content)}...`}
                
{isTrunc && ( )}
) }) )} {events.length > 0 && events.length < total && (
)}
) } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export default function CurationTab() { const [activeTab, setActiveTab] = useState('trusted') const [loading, setLoading] = useState(false) // Data const [trusted, setTrusted] = useState([]) const [blacklisted, setBlacklisted] = useState([]) const [unclassified, setUnclassified] = useState([]) const [spam, setSpam] = useState([]) const [blockedIPs, setBlockedIPs] = useState([]) // Add-form inputs const [newTrustedPk, setNewTrustedPk] = useState('') const [newTrustedNote, setNewTrustedNote] = useState('') const [newBlackPk, setNewBlackPk] = useState('') const [newBlackReason, setNewBlackReason] = useState('') // User detail const [selectedUser, setSelectedUser] = useState(null) const [selectedCategory, setSelectedCategory] = useState('unclassified') // Settings const [dailyLimit, setDailyLimit] = useState(50) const [firstBanHours, setFirstBanHours] = useState(1) const [secondBanHours, setSecondBanHours] = useState(168) // ----------------------------------------------------------------------- // Loaders // ----------------------------------------------------------------------- const loadTrusted = useCallback(async () => { try { const r = (await nip86('listtrustedpubkeys')) as TrustedEntry[] | null setTrusted(r ?? []) } catch { setTrusted([]) } }, []) const loadBlacklisted = useCallback(async () => { try { const r = (await nip86('listblacklistedpubkeys')) as BlacklistedEntry[] | null setBlacklisted(r ?? []) } catch { setBlacklisted([]) } }, []) const loadUnclassified = useCallback(async () => { try { const r = (await nip86('listunclassifiedusers')) as UnclassifiedEntry[] | null setUnclassified(r ?? []) } catch { setUnclassified([]) } }, []) const loadSpam = useCallback(async () => { try { const r = (await nip86('listspamevents')) as SpamEntry[] | null setSpam(r ?? []) } catch { setSpam([]) } }, []) const loadIPs = useCallback(async () => { try { const r = (await nip86('listblockedips')) as BlockedIP[] | null setBlockedIPs(r ?? []) } catch { setBlockedIPs([]) } }, []) const loadConfig = useCallback(async () => { try { const r = (await nip86('getcuratingconfig')) as Record | null if (r) { setDailyLimit((r.daily_limit as number) ?? 50) setFirstBanHours((r.first_ban_hours as number) ?? 1) setSecondBanHours((r.second_ban_hours as number) ?? 168) } } catch { /* keep defaults */ } }, []) const loadAll = useCallback(async () => { setLoading(true) await Promise.all([loadTrusted(), loadBlacklisted(), loadUnclassified(), loadSpam(), loadIPs(), loadConfig()]) setLoading(false) }, [loadTrusted, loadBlacklisted, loadUnclassified, loadSpam, loadIPs, loadConfig]) useEffect(() => { loadAll() }, [loadAll]) // ----------------------------------------------------------------------- // Actions // ----------------------------------------------------------------------- const trustPubkey = async (pk: string, note: string) => { if (!pk) return try { await nip86('trustpubkey', [pk, note]) toast.success('Pubkey trusted') setNewTrustedPk('') setNewTrustedNote('') await Promise.all([loadTrusted(), loadUnclassified()]) } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const untrustPubkey = async (pk: string) => { try { await nip86('untrustpubkey', [pk]) toast.success('Trust removed') await loadTrusted() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const blacklistPubkey = async (pk: string, reason: string) => { if (!pk) return try { await nip86('blacklistpubkey', [pk, reason]) toast.success('Pubkey blacklisted') setNewBlackPk('') setNewBlackReason('') await Promise.all([loadBlacklisted(), loadUnclassified()]) } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const unblacklistPubkey = async (pk: string) => { try { await nip86('unblacklistpubkey', [pk]) toast.success('Removed from blacklist') await loadBlacklisted() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const unmarkSpam = async (eventId: string) => { try { await nip86('unmarkspam', [eventId]) toast.success('Spam mark removed') await loadSpam() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const deleteEvent = async (eventId: string) => { if (!confirm('Permanently delete this event?')) return try { await nip86('deleteevent', [eventId]) toast.success('Event deleted') await loadSpam() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const unblockIP = async (ip: string) => { try { await nip86('unblockip', [ip]) toast.success('IP unblocked') await loadIPs() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const scanDatabase = async () => { try { const res = (await nip86('scanpubkeys')) as { total_pubkeys?: number total_events?: number skipped?: number } | null toast.success( `Scanned: ${res?.total_pubkeys ?? 0} pubkeys, ${res?.total_events ?? 0} events (${res?.skipped ?? 0} skipped)` ) await loadUnclassified() } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const saveSettings = async () => { setLoading(true) try { await nip86('updatecuratingconfig', [ { daily_limit: dailyLimit, first_ban_hours: firstBanHours, second_ban_hours: secondBanHours }, ]) toast.success('Settings updated') } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } } // ----------------------------------------------------------------------- // Detail view // ----------------------------------------------------------------------- if (selectedUser) { return (
setSelectedUser(null)} onChanged={loadAll} />
) } // ----------------------------------------------------------------------- // Tabs // ----------------------------------------------------------------------- return (

Curation Mode

{/* Tab bar */}
setActiveTab('trusted')}> Trusted ({trusted.length}) setActiveTab('blacklist')}> Blacklist ({blacklisted.length}) setActiveTab('unclassified')}> Unclassified ({unclassified.length}) setActiveTab('spam')}> Spam ({spam.length}) setActiveTab('ips')}> Blocked IPs ({blockedIPs.length}) setActiveTab('settings')}> Settings
{/* ===== Trusted ===== */} {activeTab === 'trusted' && (

Trusted Publishers

Trusted users can publish unlimited events without rate limiting.

setNewTrustedPk(e.target.value)} /> setNewTrustedNote(e.target.value)} />
{trusted.length === 0 ? (

No trusted pubkeys yet.

) : ( trusted.map((item) => (
{ setSelectedUser(item.pubkey) setSelectedCategory('trusted') }} >
{formatPubkey(item.pubkey)} {item.note && (

{item.note}

)}
)) )}
)} {/* ===== Blacklist ===== */} {activeTab === 'blacklist' && (

Blacklisted Publishers

Blacklisted users cannot publish any events.

setNewBlackPk(e.target.value)} /> setNewBlackReason(e.target.value)} />
{blacklisted.length === 0 ? (

No blacklisted pubkeys.

) : ( blacklisted.map((item) => (
{ setSelectedUser(item.pubkey) setSelectedCategory('blacklisted') }} >
{formatPubkey(item.pubkey)} {item.reason && (

{item.reason}

)}
)) )}
)} {/* ===== Unclassified ===== */} {activeTab === 'unclassified' && (

Unclassified Users

Users who have posted events but haven't been classified. Sorted by event count.

{unclassified.length === 0 ? (

No unclassified users.

) : ( unclassified.map((user) => (
{ setSelectedUser(user.pubkey) setSelectedCategory('unclassified') }} >
{formatPubkey(user.pubkey)} {user.event_count} events
)) )}
)} {/* ===== Spam ===== */} {activeTab === 'spam' && (

Spam Events

Events flagged as spam are hidden from query results but remain in the database.

{spam.length === 0 ? (

No spam events flagged.

) : ( spam.map((ev) => (
{formatPubkey(ev.event_id)} by {formatPubkey(ev.pubkey)} {ev.reason && (

{ev.reason}

)}
)) )}
)} {/* ===== Blocked IPs ===== */} {activeTab === 'ips' && (

Blocked IP Addresses

IP addresses blocked due to rate limit violations.

{blockedIPs.length === 0 ? (

No blocked IPs.

) : ( blockedIPs.map((ip) => (
{ip.ip} {ip.reason && ( {ip.reason} )} {ip.expires_at && ( Expires: {formatDate(ip.expires_at)} )}
)) )}
)} {/* ===== Settings ===== */} {activeTab === 'settings' && (

Rate Limiting

Configure rate limits for unclassified users and IP ban durations.

)}
) }