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>
251 lines
8.7 KiB
TypeScript
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';
|
|
}
|
|
}
|