SkateSpots/src/hooks/useActivityNotifications.ts
shakespeare.diy 854d18c322 Give admins moderator control over events + fix a spoofing hole
Admins (the two pubkeys in src/lib/admins.ts) can now moderate any event:
- Post authoritative Event Updates (incl. cancel/reschedule) on events they don't own,
  clearly labelled "Admin" and marked with an ["admin","true"] tag
- Take down any event (already wired via DeleteEntityDialog + isAdminPubkey)

SECURITY FIX: kind-7103 Event Updates are now only trusted from the event's host
(pubkey in the `a` coordinate) or a SkateSpots admin. Previously ANY npub could
publish a fake "CANCELLED" update — spoofing the detail-page banner and spamming
RSVPers' notification bells. Enforced in both parseEventUpdate() and
useActivityNotifications.

Note: admins intentionally CANNOT open the edit form on others' events — an
addressable event (31923) can only be replaced by the key that signed it; editing
under a different key would fork it, not edit it. Moderation is done via
authoritative updates + takedown instead.

Docs: NIP.md updated with the host/admin trust rule and the admin marker tag.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 14:40:17 +07:00

251 lines
8.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';
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';
}
}