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
This commit is contained in:
Chad Curtis
2026-02-19 04:25:30 -06:00
parent 16e20df0cc
commit 8a4ea45035
9 changed files with 333 additions and 16 deletions
+20 -7
View File
@@ -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": [
+1
View File
@@ -53,6 +53,7 @@ const defaultConfig: AppConfig = {
feedIncludeColors: false,
feedIncludePacks: false,
},
nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
};
export function App() {
+76 -1
View File
@@ -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 (
<div>
@@ -76,6 +100,57 @@ export function AdvancedSettings() {
</CollapsibleContent>
</Collapsible>
</div>
{/* Stats Source Section */}
<div>
<Collapsible open={statsOpen} onOpenChange={setStatsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 rounded-none border-b-[4px] border-primary"
>
<span className="text-base font-semibold">Stats Source</span>
{statsOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pb-4 pt-4 px-3 space-y-3">
<div>
<Label htmlFor="stats-pubkey" className="text-sm font-medium">
NIP-85 Stats Pubkey
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Enter a trusted pubkey for pre-computed engagement stats (likes, reposts, comments).
Leave empty to always calculate stats manually.
</p>
<Input
id="stats-pubkey"
value={statsPubkey}
onChange={(e) => handleStatsPubkeyChange(e.target.value)}
placeholder="Enter 64-character hex pubkey"
className="font-mono text-sm"
maxLength={64}
/>
{statsPubkey && statsPubkey.length !== 64 && (
<p className="text-xs text-destructive mt-1">
Pubkey must be exactly 64 hexadecimal characters
</p>
)}
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Default pubkey:</p>
<p className="font-mono break-all">
5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea
</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}
+1
View File
@@ -44,6 +44,7 @@ const AppConfigSchema = z.object({
relayMetadata: RelayMetadataSchema,
useAppRelays: z.boolean(),
feedSettings: FeedSettingsSchema,
nip85StatsPubkey: z.string().length(64),
}) satisfies z.ZodType<AppConfig>;
export function AppProvider(props: AppProviderProps) {
+2
View File
@@ -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 {
+33 -4
View File
@@ -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,
+24 -4
View File
@@ -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<EventInteractions>({
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,
+175
View File
@@ -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<Nip85EventStats | undefined>({
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<Nip85UserStats | undefined>({
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<Nip85EventStats | undefined>({
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
});
}
+1
View File
@@ -45,6 +45,7 @@ export function TestApp({ children }: TestAppProps) {
feedIncludeColors: false,
feedIncludePacks: false,
},
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
};
return (