Files
eranos/src/hooks/useNip85Stats.ts
T
Derek Ross 854f9aca23 Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts
- Fix silent notification drops: reactions, reposts, and zaps were being
  discarded when the referenced event couldn't be fetched from relays.
  Now keeps notifications with missing context instead of hiding them.
  Zaps no longer require the author-ownership check since the #p filter
  already confirms the user is the recipient. (fixes tabs appearing
  identical and missing zap notifications)

- Add real-time WebSocket subscriptions for instant notification updates
  instead of relying solely on 60-second polling. Both the full
  notification list and the unread dot indicator now react immediately
  when new events arrive.

- Wire up zap amounts from NIP-85 stats (zap_amount tag on kind 30383)
  through to the NoteCard action bar, replacing the hardcoded 0.

- Seed client-side reply counts into the event-stats cache from the
  loaded comment tree in PostDetailPage, ensuring sub-comment counts
  are visible even when NIP-85 stats are unavailable for kind 1111.

Closes #136
2026-03-22 15:42:45 -04:00

173 lines
4.8 KiB
TypeScript

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;
zapAmount: 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.
*
* Stats lookups are batched automatically: 20 NoteCards mounting in the same
* frame produce a single REQ with `#d: [id1, ..., id20]` instead of 20
* separate REQs, thanks to the NostrBatcher proxy.
*/
export function useNip85EventStats(eventId: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85EventStats | null>({
queryKey: ['nip85-event-stats', eventId, statsPubkey],
queryFn: async ({ signal }) => {
if (!eventId || !statsPubkey) return null;
try {
const events = await nostr.query(
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
{ signal },
);
if (events.length === 0) return null;
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'),
zapAmount: getTagValue('zap_amount'),
};
} catch {
return null;
}
},
enabled: !!eventId && !!statsPubkey,
staleTime: 30 * 1000,
retry: false,
});
}
/**
* 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 | null>({
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
queryFn: async ({ signal }) => {
if (!pubkey || !statsPubkey) return null;
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 null;
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 null;
}
},
enabled: !!pubkey && !!statsPubkey,
staleTime: 60 * 1000,
retry: false,
});
}
/**
* 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 | null>({
queryKey: ['nip85-addr-stats', addr, statsPubkey],
queryFn: async ({ signal }) => {
if (!addr || !statsPubkey) return null;
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 null;
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'),
zapAmount: getTagValue('zap_amount'),
};
} catch {
return null;
}
},
enabled: !!addr && !!statsPubkey,
staleTime: 30 * 1000,
retry: false,
});
}