SkateSpots/src/hooks/useActivityNotifications.ts
SkateSpots 63dc4bee7d feat(notify): opt-in on-device OS notifications (Phase 1)
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.

- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
  uses @capacitor/local-notifications (lazily imported, so the web bundle never
  pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
  opted in. Per-source silent priming + a seen-id set means history is never
  replayed as a flood, and it stays quiet while the app is visible (the bell
  already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
  no decryption) — so Amber is never prompted just to be told you have a
  message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
  default off, per-category toggles. Settings -> Notifications UI.

Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
2026-09-29 12:59:13 +07:00

252 lines
8.7 KiB
TypeScript

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,
isFetched: query.isFetched,
};
}
/** 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';
}
}