import { useMemo } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isAdminPubkey } from '@/lib/admins'; /** * General, app-wide activity notifications for the logged-in user. * ───────────────────────────────────────────────────────────────────────────── * This complements the crew-specific notifications (invites / join-requests) * with the "social" notifications any Nostr user expects: reactions, reposts, * zaps, mentions and replies/comments that reference THEM. * * How it works: we do ONE combined query for events that tag the user's pubkey * (`#p`), across the relevant kinds, then classify them in JS. This keeps relay * load minimal (a single filter) per the project's query-efficiency rules. */ export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp' | 'event-update'; export interface ActivityNotification { id: string; type: ActivityType; /** Who triggered it (reactor / reposter / zapper / author). */ pubkey: string; createdAt: number; /** The raw event, for rendering extra context (reaction emoji, comment text). */ event: NostrEvent; /** For reactions: the emoji/content ("+", "🛹", …). */ content?: string; /** The event id this activity points at (their content), if any. */ targetEventId?: string; /** Sats amount for zaps, parsed from the bolt11/description when available. */ amountSats?: number; /** For RSVPs: accepted / tentative / declined. */ rsvpStatus?: 'accepted' | 'tentative' | 'declined'; } const REACTION_KIND = 7; const REPOST_KINDS = [6, 16]; const ZAP_RECEIPT_KIND = 9735; const TEXT_NOTE_KIND = 1; const COMMENT_KIND = 1111; const RSVP_KIND = 31925; const EVENT_UPDATE_KIND = 7103; /** Parse the sats amount out of a zap receipt (9735) if we can. */ function parseZapAmount(event: NostrEvent): number | undefined { // Zap receipts embed the original zap request in a "description" tag; that // request usually carries an "amount" tag in millisats. try { const desc = event.tags.find(([t]) => t === 'description')?.[1]; if (desc) { const req = JSON.parse(desc) as { tags?: string[][] }; const amountMsat = req.tags?.find((t) => t[0] === 'amount')?.[1]; if (amountMsat) { const msat = parseInt(amountMsat, 10); if (Number.isFinite(msat) && msat > 0) return Math.round(msat / 1000); } } } catch { // ignore malformed description } return undefined; } /** The pubkey that actually SENT a zap (the zapper), from the receipt. */ function zapSenderPubkey(event: NostrEvent): string { // Prefer the "P" (uppercase) tag = sender pubkey (NIP-57), then the embedded // request's pubkey, then fall back to the receipt author (the LN service). const upperP = event.tags.find(([t]) => t === 'P')?.[1]; if (upperP) return upperP; try { const desc = event.tags.find(([t]) => t === 'description')?.[1]; if (desc) { const req = JSON.parse(desc) as { pubkey?: string }; if (req.pubkey) return req.pubkey; } } catch { // ignore } return event.pubkey; } export function useActivityNotifications() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const query = useQuery({ queryKey: ['activity-notifications', user?.pubkey], enabled: !!user?.pubkey, // Refresh periodically so the bell stays fresh without hammering relays. staleTime: 60_000, refetchInterval: 90_000, queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(4000)]); const pubkey = user!.pubkey; // Single combined query: everything that tags me. const events = await nostr.query( [ { kinds: [ REACTION_KIND, ...REPOST_KINDS, ZAP_RECEIPT_KIND, TEXT_NOTE_KIND, COMMENT_KIND, RSVP_KIND, EVENT_UPDATE_KIND, ], '#p': [pubkey], limit: 120, }, ], { signal }, ); const out: ActivityNotification[] = []; for (const event of events) { // Never notify the user about their own actions. if (event.pubkey === pubkey && event.kind !== ZAP_RECEIPT_KIND) continue; const targetEventId = event.tags.find(([t]) => t === 'e')?.[1]; if (event.kind === REACTION_KIND) { out.push({ id: event.id, type: 'reaction', pubkey: event.pubkey, createdAt: event.created_at, event, content: event.content || '+', targetEventId, }); } else if (REPOST_KINDS.includes(event.kind)) { out.push({ id: event.id, type: 'repost', pubkey: event.pubkey, createdAt: event.created_at, event, targetEventId, }); } else if (event.kind === ZAP_RECEIPT_KIND) { const sender = zapSenderPubkey(event); if (sender === pubkey) continue; // don't notify on self-zaps out.push({ id: event.id, type: 'zap', pubkey: sender, createdAt: event.created_at, event, targetEventId, amountSats: parseZapAmount(event), }); } else if (event.kind === TEXT_NOTE_KIND) { // A kind-1 that p-tags me: either a reply to my note or a mention. const isReply = event.tags.some(([t]) => t === 'e'); out.push({ id: event.id, type: isReply ? 'reply' : 'mention', pubkey: event.pubkey, createdAt: event.created_at, event, content: event.content, targetEventId, }); } else if (event.kind === COMMENT_KIND) { out.push({ id: event.id, type: 'reply', pubkey: event.pubkey, createdAt: event.created_at, event, content: event.content, targetEventId, }); } else if (event.kind === RSVP_KIND) { // Someone RSVP'd to an event I'm hosting (RSVP p-tags the host). const status = event.tags.find(([t]) => t === 'status')?.[1]; if (status !== 'accepted' && status !== 'tentative' && status !== 'declined') continue; out.push({ id: event.id, type: 'rsvp', pubkey: event.pubkey, createdAt: event.created_at, event, content: event.content, targetEventId, rsvpStatus: status, }); } else if (event.kind === EVENT_UPDATE_KIND) { // A host posted an update to an event I RSVP'd to (I'm p-tagged). // SECURITY: only trust it if the author is the event's host (from the // `a` coordinate) or a SkateSpots admin — otherwise a stranger could // spam fake "event cancelled" notifications by p-tagging RSVPers. const updateCoord = event.tags.find(([t]) => t === 'a')?.[1]; const updateHost = updateCoord?.split(':')[1]; if (event.pubkey !== updateHost && !isAdminPubkey(event.pubkey)) continue; out.push({ id: event.id, type: 'event-update', pubkey: event.pubkey, createdAt: event.created_at, event, content: event.content, targetEventId, }); } } // Newest first. out.sort((a, b) => b.createdAt - a.createdAt); return out; }, }); return { notifications: query.data ?? [], isLoading: query.isLoading, }; } /** Human-readable verb for each activity type. */ export function activityVerb(n: ActivityNotification): string { switch (n.type) { case 'reaction': return n.content && n.content !== '+' ? `reacted ${n.content}` : 'liked your post'; case 'repost': return 'reposted you'; case 'zap': return n.amountSats ? `zapped you ${n.amountSats.toLocaleString()} sats` : 'zapped you'; case 'mention': return 'mentioned you'; case 'reply': return 'replied to you'; case 'rsvp': return n.rsvpStatus === 'accepted' ? "is going to your event" : n.rsvpStatus === 'tentative' ? 'is interested in your event' : "can't make your event"; case 'event-update': return 'posted an event update'; default: return 'interacted with you'; } }