import { useEffect, useMemo, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents'; import { eventCoord } from '@/hooks/useEventRsvps'; import { CALENDAR_RSVP_KIND, parseEventRsvp } from '@/lib/types'; import { cancelAllScheduledReminders, syncScheduledReminders, type Reminder } from '@/lib/reminders'; /** * Schedules an OS reminder an hour before each upcoming event the user RSVP'd * "going" or "interested" to. Because the OS holds the alarm, it fires even when * the app is closed — the one closed-app notification that needs no server. * * Uses only existing queries (upcoming events + the user's own RSVPs); no new * relay destination, no signing, nothing decrypted. */ const REMINDER_LEAD_MS = 60 * 60 * 1000; // one hour before the start const MIN_NOTICE_MS = 5 * 60 * 1000; // ignore events starting within 5 minutes const SOON_MS = 60 * 1000; // ...but if the lead already passed, fire in a minute /** The coordinates of events the user has said they're going to / interested in. */ function useMyRsvpCoords(): Set { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { user } = useCurrentUser(); const query = useQuery({ queryKey: ['my-rsvp-coords', 'reminders', user?.pubkey, getQueryKeySuffix()], enabled: !!user?.pubkey, staleTime: 60_000, queryFn: async (c): Promise => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await querySkateContent( [{ kinds: [CALENDAR_RSVP_KIND], authors: [user!.pubkey], limit: 200 }], { signal }, ); const coords: string[] = []; for (const event of events) { const rsvp = parseEventRsvp(event); if (rsvp && (rsvp.status === 'accepted' || rsvp.status === 'tentative')) { coords.push(rsvp.eventCoord); } } return coords; }, }); return useMemo(() => new Set(query.data ?? []), [query.data]); } /** * Live only while the caller renders it (guarded to native + opted in). When it * unmounts — notifications turned off, or logged out — every reminder it * scheduled is cancelled. */ export function useLocalReminders(): void { const { data: events = [] } = useUpcomingSkateEvents(100); const myCoords = useMyRsvpCoords(); const signatureRef = useRef(null); useEffect( () => () => { void cancelAllScheduledReminders(); }, [], ); useEffect(() => { const now = Date.now(); const reminders: Reminder[] = []; for (const event of events) { const coord = eventCoord(event); if (!myCoords.has(coord)) continue; const startMs = event.start * 1000; if (startMs - now < MIN_NOTICE_MS) continue; // already started / imminent const at = Math.max(startMs - REMINDER_LEAD_MS, now + SOON_MS); reminders.push({ key: `event:${coord}`, title: `🛹 ${event.title}`, body: event.location ? `Starts soon at ${event.location}` : 'Your event starts soon', path: `/event/${event.pubkey}/${event.identifier}`, at, }); } // Only talk to the OS when the desired set actually changed (the "fire soon" // time drifts by the minute, so bucket it). const signature = reminders .map((r) => `${r.key}@${Math.floor(r.at / 60_000)}`) .sort() .join('|'); if (signature === signatureRef.current) return; signatureRef.current = signature; void syncScheduledReminders(reminders); }, [events, myCoords]); }