SkateSpots/src/hooks/useLocalReminders.ts

101 lines
3.6 KiB
TypeScript

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<string> {
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<string[]> => {
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<string | null>(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]);
}