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 (