From 8a4ea45035b7bfbe646d455fb7de417450c57d2b Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Thu, 19 Feb 2026 04:25:30 -0600 Subject: [PATCH] Add NIP-85 stats integration with fallback to manual calculation - Add nip85StatsPubkey to AppConfig (default: 5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea) - Create useNip85Stats hook for querying NIP-85 events (kinds 30382, 30383, 30384) - Update useEventInteractions to fetch NIP-85 event stats and reduce query limits when available - Update useComments to fetch NIP-85 stats for events and addressable events - Add NIP-85 Stats Source section to Advanced Settings with pubkey input field - Falls back to manual stats calculation when NIP-85 events are not available --- package-lock.json | 27 +++-- src/App.tsx | 1 + src/components/AdvancedSettings.tsx | 77 +++++++++++- src/components/AppProvider.tsx | 1 + src/contexts/AppContext.ts | 2 + src/hooks/useComments.ts | 37 +++++- src/hooks/useEventInteractions.ts | 28 ++++- src/hooks/useNip85Stats.ts | 175 ++++++++++++++++++++++++++++ src/test/TestApp.tsx | 1 + 9 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useNip85Stats.ts diff --git a/package-lock.json b/package-lock.json index ea545234..7a990cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@fontsource-variable/inter": "^5.2.6", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^5.2.2", - "@lucide/lab": "^0.1.2", "@nostrify/nostrify": "^0.50.0", "@nostrify/react": "^0.2.25", "@radix-ui/react-accordion": "^1.2.0", @@ -1176,12 +1175,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lucide/lab": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@lucide/lab/-/lab-0.1.2.tgz", - "integrity": "sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA==", - "license": "ISC" - }, "node_modules/@noble/ciphers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", @@ -2741,6 +2734,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2754,6 +2748,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2767,6 +2762,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2780,6 +2776,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2793,6 +2790,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2806,6 +2804,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2819,6 +2818,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2832,6 +2832,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2845,6 +2846,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2858,6 +2860,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2871,6 +2874,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2884,6 +2888,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2897,6 +2902,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2910,6 +2916,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2923,6 +2930,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2936,6 +2944,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2949,6 +2958,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2962,6 +2972,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2975,6 +2986,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2988,6 +3000,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/App.tsx b/src/App.tsx index 8db32e22..2d48843a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ const defaultConfig: AppConfig = { feedIncludeColors: false, feedIncludePacks: false, }, + nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea", }; export function App() { diff --git a/src/components/AdvancedSettings.tsx b/src/components/AdvancedSettings.tsx index b4e27ef0..3e1c52d8 100644 --- a/src/components/AdvancedSettings.tsx +++ b/src/components/AdvancedSettings.tsx @@ -1,15 +1,39 @@ import { useState } from 'react'; -import { ChevronDown, ChevronUp, Wrench } from 'lucide-react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { WalletSettings } from '@/components/WalletSettings'; import { RelayListManager } from '@/components/RelayListManager'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useToast } from '@/hooks/useToast'; export function AdvancedSettings() { const { user } = useCurrentUser(); + const { config, updateConfig } = useAppContext(); + const { toast } = useToast(); const [walletOpen, setWalletOpen] = useState(false); const [relaysOpen, setRelaysOpen] = useState(false); + const [statsOpen, setStatsOpen] = useState(false); + const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey); + + const handleStatsPubkeyChange = (value: string) => { + setStatsPubkey(value); + + // Validate hex format (64 characters) + if (value.length === 64 && /^[0-9a-f]{64}$/i.test(value)) { + updateConfig(() => ({ nip85StatsPubkey: value.toLowerCase() })); + toast({ + title: 'Stats source updated', + description: 'NIP-85 stats pubkey has been saved.', + }); + } else if (value.length === 0) { + // Allow clearing the field + updateConfig(() => ({ nip85StatsPubkey: '' })); + } + }; return (
@@ -76,6 +100,57 @@ export function AdvancedSettings() {
+ + {/* Stats Source Section */} +
+ + + + + +
+
+ +

+ Enter a trusted pubkey for pre-computed engagement stats (likes, reposts, comments). + Leave empty to always calculate stats manually. +

+ handleStatsPubkeyChange(e.target.value)} + placeholder="Enter 64-character hex pubkey" + className="font-mono text-sm" + maxLength={64} + /> + {statsPubkey && statsPubkey.length !== 64 && ( +

+ Pubkey must be exactly 64 hexadecimal characters +

+ )} +
+
+

Default pubkey:

+

+ 5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea +

+
+
+
+
+
); } diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index a3e87775..553b49a0 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -44,6 +44,7 @@ const AppConfigSchema = z.object({ relayMetadata: RelayMetadataSchema, useAppRelays: z.boolean(), feedSettings: FeedSettingsSchema, + nip85StatsPubkey: z.string().length(64), }) satisfies z.ZodType; export function AppProvider(props: AppProviderProps) { diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index d75fb2c4..6c04d566 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -48,6 +48,8 @@ export interface AppConfig { useAppRelays: boolean; /** Feed and sidebar content settings */ feedSettings: FeedSettings; + /** NIP-85 stats pubkey source (hex format) */ + nip85StatsPubkey: string; } export interface AppContextType { diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index 5fdf42be..aa03afc0 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -1,13 +1,41 @@ import { NKinds, NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; +import { useNip85EventStats, useNip85AddrStats } from '@/hooks/useNip85Stats'; export function useComments(root: NostrEvent | URL, limit?: number) { const { nostr } = useNostr(); + // Determine the identifier for NIP-85 stats + let nip85Identifier: string | undefined; + let nip85Type: 'event' | 'addr' | undefined; + + if (root instanceof URL) { + // URLs don't have NIP-85 stats + nip85Identifier = undefined; + } else if (NKinds.addressable(root.kind)) { + const d = root.tags.find(([name]) => name === 'd')?.[1] ?? ''; + nip85Identifier = `${root.kind}:${root.pubkey}:${d}`; + nip85Type = 'addr'; + } else if (NKinds.replaceable(root.kind)) { + nip85Identifier = `${root.kind}:${root.pubkey}:`; + nip85Type = 'addr'; + } else { + nip85Identifier = root.id; + nip85Type = 'event'; + } + + const nip85EventStats = useNip85EventStats(nip85Type === 'event' ? nip85Identifier : undefined); + const nip85AddrStats = useNip85AddrStats(nip85Type === 'addr' ? nip85Identifier : undefined); + const nip85Stats = nip85Type === 'event' ? nip85EventStats.data : nip85AddrStats.data; + return useQuery({ - queryKey: ['nostr', 'comments', root instanceof URL ? root.toString() : root.id, limit], + queryKey: ['nostr', 'comments', root instanceof URL ? root.toString() : root.id, limit, nip85Stats], queryFn: async (c) => { + // If we have NIP-85 comment count and no limit specified, use a smaller default limit + const hasNip85CommentCount = !!nip85Stats?.commentCount; + const effectiveLimit = limit ?? (hasNip85CommentCount ? 10 : undefined); + const filter: NostrFilter = { kinds: [1111] }; if (root instanceof URL) { @@ -21,8 +49,8 @@ export function useComments(root: NostrEvent | URL, limit?: number) { filter['#E'] = [root.id]; } - if (typeof limit === 'number') { - filter.limit = limit; + if (typeof effectiveLimit === 'number') { + filter.limit = effectiveLimit; } // Query for all kind 1111 comments that reference this addressable event regardless of depth @@ -90,7 +118,8 @@ export function useComments(root: NostrEvent | URL, limit?: number) { }); // Sort direct replies by creation time (oldest first for threaded display) return directReplies.sort((a, b) => a.created_at - b.created_at); - } + }, + nip85Stats, }; }, enabled: !!root, diff --git a/src/hooks/useEventInteractions.ts b/src/hooks/useEventInteractions.ts index ef3f3023..4c07b50a 100644 --- a/src/hooks/useEventInteractions.ts +++ b/src/hooks/useEventInteractions.ts @@ -1,6 +1,7 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; +import { useNip85EventStats } from '@/hooks/useNip85Stats'; export interface RepostEntry { pubkey: string; @@ -32,6 +33,13 @@ export interface EventInteractions { quotes: QuoteEntry[]; reactions: ReactionEntry[]; zaps: ZapEntry[]; + /** NIP-85 stats if available */ + nip85Stats?: { + commentCount: number; + repostCount: number; + reactionCount: number; + zapCount: number; + }; } /** Extracts the zap amount in millisatoshis from a kind 9735 zap receipt. */ @@ -117,20 +125,26 @@ function extractZapMessage(event: NostrEvent): string { /** Fetches interaction events (reposts, quotes, reactions, zaps) for a given event ID. */ export function useEventInteractions(eventId: string | undefined) { const { nostr } = useNostr(); + const nip85Stats = useNip85EventStats(eventId); return useQuery({ - queryKey: ['event-interactions', eventId ?? ''], + queryKey: ['event-interactions', eventId ?? '', nip85Stats.data], queryFn: async ({ signal }) => { if (!eventId) return { reposts: [], quotes: [], reactions: [], zaps: [] }; + // Try NIP-85 stats first - if available, use a smaller limit for actual events + const hasNip85Stats = !!nip85Stats.data; + const interactionLimit = hasNip85Stats ? 10 : 50; + const quoteLimit = hasNip85Stats ? 5 : 20; + const timeout = AbortSignal.timeout(5000); const combined = AbortSignal.any([signal, timeout]); // Single query with two filter objects — relay handles as OR const allEvents = await nostr.query( [ - { kinds: [6, 7, 9735], '#e': [eventId], limit: 50 }, - { kinds: [1], '#q': [eventId], limit: 20 }, + { kinds: [6, 7, 9735], '#e': [eventId], limit: interactionLimit }, + { kinds: [1], '#q': [eventId], limit: quoteLimit }, ], { signal: combined }, ); @@ -191,7 +205,13 @@ export function useEventInteractions(eventId: string | undefined) { reactions.sort((a, b) => b.createdAt - a.createdAt); zaps.sort((a, b) => b.amountSats - a.amountSats); // Sort zaps by amount (largest first) - return { reposts, quotes, reactions, zaps }; + return { + reposts, + quotes, + reactions, + zaps, + nip85Stats: nip85Stats.data, + }; }, enabled: !!eventId, staleTime: 60 * 1000, diff --git a/src/hooks/useNip85Stats.ts b/src/hooks/useNip85Stats.ts new file mode 100644 index 00000000..c5b0b3da --- /dev/null +++ b/src/hooks/useNip85Stats.ts @@ -0,0 +1,175 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { useAppContext } from '@/hooks/useAppContext'; + +export interface Nip85EventStats { + commentCount: number; + repostCount: number; + reactionCount: number; + zapCount: number; +} + +export interface Nip85UserStats { + followers: number; + postCount: number; +} + +/** + * Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey. + * Returns undefined if no stats are available. + */ +export function useNip85EventStats(eventId: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-event-stats', eventId, statsPubkey], + queryFn: async ({ signal }) => { + if (!eventId || !statsPubkey) return undefined; + + const timeout = AbortSignal.timeout(2000); + const combined = AbortSignal.any([signal, timeout]); + + try { + const events = await nostr.query( + [ + { + kinds: [30383], + authors: [statsPubkey], + '#d': [eventId], + limit: 1, + }, + ], + { signal: combined }, + ); + + if (events.length === 0) return undefined; + + const event = events[0]; + const getTagValue = (tagName: string): number => { + const tag = event.tags.find(([name]) => name === tagName); + return tag?.[1] ? parseInt(tag[1], 10) : 0; + }; + + return { + commentCount: getTagValue('comment_cnt'), + repostCount: getTagValue('repost_cnt'), + reactionCount: getTagValue('reaction_cnt'), + zapCount: getTagValue('zap_cnt'), + }; + } catch { + return undefined; + } + }, + enabled: !!eventId && !!statsPubkey, + staleTime: 30 * 1000, // 30 seconds + retry: false, // Don't retry on failure, fall back to manual calculation + }); +} + +/** + * Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey. + * Returns undefined if no stats are available. + */ +export function useNip85UserStats(pubkey: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-user-stats', pubkey, statsPubkey], + queryFn: async ({ signal }) => { + if (!pubkey || !statsPubkey) return undefined; + + const timeout = AbortSignal.timeout(2000); + const combined = AbortSignal.any([signal, timeout]); + + try { + const events = await nostr.query( + [ + { + kinds: [30382], + authors: [statsPubkey], + '#d': [pubkey], + limit: 1, + }, + ], + { signal: combined }, + ); + + if (events.length === 0) return undefined; + + const event = events[0]; + const getTagValue = (tagName: string): number => { + const tag = event.tags.find(([name]) => name === tagName); + return tag?.[1] ? parseInt(tag[1], 10) : 0; + }; + + return { + followers: getTagValue('followers'), + postCount: getTagValue('post_cnt'), + }; + } catch { + return undefined; + } + }, + enabled: !!pubkey && !!statsPubkey, + staleTime: 60 * 1000, // 1 minute + retry: false, // Don't retry on failure, fall back to manual calculation + }); +} + +/** + * Fetches NIP-85 addressable event stats (kind 30384) from the configured stats pubkey. + * Returns undefined if no stats are available. + */ +export function useNip85AddrStats(addr: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-addr-stats', addr, statsPubkey], + queryFn: async ({ signal }) => { + if (!addr || !statsPubkey) return undefined; + + const timeout = AbortSignal.timeout(2000); + const combined = AbortSignal.any([signal, timeout]); + + try { + const events = await nostr.query( + [ + { + kinds: [30384], + authors: [statsPubkey], + '#d': [addr], + limit: 1, + }, + ], + { signal: combined }, + ); + + if (events.length === 0) return undefined; + + const event = events[0]; + const getTagValue = (tagName: string): number => { + const tag = event.tags.find(([name]) => name === tagName); + return tag?.[1] ? parseInt(tag[1], 10) : 0; + }; + + return { + commentCount: getTagValue('comment_cnt'), + repostCount: getTagValue('repost_cnt'), + reactionCount: getTagValue('reaction_cnt'), + zapCount: getTagValue('zap_cnt'), + }; + } catch { + return undefined; + } + }, + enabled: !!addr && !!statsPubkey, + staleTime: 30 * 1000, // 30 seconds + retry: false, // Don't retry on failure, fall back to manual calculation + }); +} diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 91a582a8..d4b7ace1 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -45,6 +45,7 @@ export function TestApp({ children }: TestAppProps) { feedIncludeColors: false, feedIncludePacks: false, }, + nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', }; return (