SkateSpots/src/hooks/useActivityNotifications.ts
shakespeare.diy 7439b980bb Events Phase 1: NIP-52 RSVPs, attendee stacks, date filter, host notifications
- Adopt NIP-52 RSVP (kind 31925): parseEventRsvp + RsvpStatus types
- useEventRsvps (counts by status, attendee pubkeys, my status) + useRsvp
  mutation that replaces the user's prior RSVP via a stable d-tag
- RsvpButtons (Going / Interested / Can't make it) + AttendeeStack avatar row
- EventDetailPage: "Who's going" card with RSVP control + attendee avatars
- EventCard: going count on both compact and full cards
- EventsPage: quick date-window chips (Today / Weekend / Week / Month)
- Activity bell: host RSVP notifications (new 'rsvp' activity type)
- Document RSVP schema in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 10:47:57 +07:00

229 lines
7.7 KiB
TypeScript

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';
/**
* 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';
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;
/** 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,
],
'#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,
});
}
}
// 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";
default:
return 'interacted with you';
}
}