101 lines
3.6 KiB
TypeScript
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]);
|
|
}
|