diff --git a/AGENTS.md b/AGENTS.md index 8a0041d..d26f84b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -554,6 +554,15 @@ session with **no other memory of this project**. So: opening the app never replays history as a flood; **(3) suppressed while the document is visible** (the bell already covers that). Delivery is **local only** — a fully closed app/browser is unreachable without a server push. + **Phase 1b — scheduled reminders (2026-09-29):** `lib/reminders.ts` + + `useLocalReminders` hand the OS an alarm 1 h before each upcoming event the + user RSVP'd going/interested. Native-only; the hook is mounted from the bridge + only when `prefs.reminders` AND `isNativePlatform()`. Because the **OS holds + the alarm it fires with the app fully closed** — the only closed-app + notification achievable without a server. Scheduled ids live in + `skatespots:notifications:reminders`; the hook cancels them all on unmount + (opt-out / logout). New pref key `reminders` (default on). Pro-expiry reminder + deliberately skipped — it would pull in the heavy `useSasquatchPro` query. **Phase 2/3 (NOT built):** background runner (on-device polling, closed-app, ~15 min floor) and a content-free FCM "doorbell" (timely, but a server learns metadata). `useResolvedDmRelays` is exported for the envelope hook. diff --git a/package-lock.json b/package-lock.json index f04abba..50a631b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "skate-spots", - "version": "1.5.6", + "version": "1.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "skate-spots", - "version": "1.5.6", + "version": "1.5.7", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/android": "^8.4.2", diff --git a/package.json b/package.json index d61a762..29ee5f5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.5.6", + "version": "1.5.7", "type": "module", "homepage": "https://skate-spots.org", "scripts": { diff --git a/release-notes.txt b/release-notes.txt index ff2d8a0..e51d9bc 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,3 +1,12 @@ +v1.5.7 + +⏰ Event reminders +- RSVP "going" or "interested" to an event and your phone nudges you an hour + before it starts — even when SkateSpots is closed. It's an alarm the device + holds, not a server, so nothing leaves your phone. +- Turn it on in Settings -> Notifications (Android app). + +--- v1.5.6 🛹 The map is quicker off the mark diff --git a/src/components/OsNotificationBridge.tsx b/src/components/OsNotificationBridge.tsx index b8c4ebe..a7ded11 100644 --- a/src/components/OsNotificationBridge.tsx +++ b/src/components/OsNotificationBridge.tsx @@ -7,6 +7,8 @@ import { useNewsletterIssues } from '@/hooks/useNewsletterIssues'; import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription'; import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes'; import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; +import { useLocalReminders } from '@/hooks/useLocalReminders'; +import { isNativePlatform } from '@/lib/platform'; import { getNotificationPermission, listenForNotificationTaps, @@ -331,5 +333,13 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) { prefs, ]); + // Scheduled reminders are native-only (the web has no scheduler), so only + // mount the hook — and therefore only run its event/RSVP queries — when the + // platform can actually use them. + return prefs.reminders && isNativePlatform() ? : null; +} + +function LocalReminders() { + useLocalReminders(); return null; } diff --git a/src/hooks/useLocalReminders.ts b/src/hooks/useLocalReminders.ts new file mode 100644 index 0000000..7683a21 --- /dev/null +++ b/src/hooks/useLocalReminders.ts @@ -0,0 +1,101 @@ +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', 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]); +} diff --git a/src/lib/notificationPrefs.ts b/src/lib/notificationPrefs.ts index 05c14c3..eb86e98 100644 --- a/src/lib/notificationPrefs.ts +++ b/src/lib/notificationPrefs.ts @@ -9,7 +9,7 @@ * nothing is requested from the OS until the user turns them on in Settings. */ -export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter'; +export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter' | 'reminders'; export interface NotificationPrefs { /** Master switch. Off until the user explicitly enables notifications. */ @@ -22,6 +22,8 @@ export interface NotificationPrefs { social: boolean; /** New SkateSpots newsletter drops (subscribers only). */ newsletter: boolean; + /** Scheduled event reminders (Android app — fires even when the app is closed). */ + reminders: boolean; } export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs'; @@ -32,6 +34,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { crew: true, social: true, newsletter: true, + reminders: true, }; /** Display metadata for the per-category toggles, in order. */ @@ -44,4 +47,5 @@ export const NOTIFICATION_CATEGORIES: Array<{ { key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' }, { key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' }, { key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' }, + { key: 'reminders', label: 'Event reminders', hint: "A nudge an hour before events you're going to (Android app)" }, ]; diff --git a/src/lib/osNotify.ts b/src/lib/osNotify.ts index c6e58aa..f87b0f4 100644 --- a/src/lib/osNotify.ts +++ b/src/lib/osNotify.ts @@ -80,7 +80,7 @@ export interface OsNotificationInput { } /** Stable 32-bit int for the native notification id (which must be numeric). */ -function numericId(id: string): number { +export function numericId(id: string): number { let hash = 0; for (let i = 0; i < id.length; i++) { hash = (hash * 31 + id.charCodeAt(i)) | 0; diff --git a/src/lib/reminders.ts b/src/lib/reminders.ts new file mode 100644 index 0000000..ecf533e --- /dev/null +++ b/src/lib/reminders.ts @@ -0,0 +1,119 @@ +import type { LocalNotificationSchema } from '@capacitor/local-notifications'; +import { isNativePlatform } from '@/lib/platform'; +import { getNotificationPermission, numericId } from '@/lib/osNotify'; + +/** + * Scheduled, future OS reminders — the only kind of notification that still + * fires when the app is **fully closed**, because the OS holds the alarm. No + * server, no background process, no network call: the app hands the OS a title, + * a time and an in-app path, and the OS does the rest. + * + * Native (Android/iOS) only — the web has no scheduler. On web every function + * here is a quiet no-op. + * + * We track the ids we handed out in localStorage so a reminder that no longer + * applies (RSVP withdrawn, event passed) can be cancelled without disturbing + * the one-off notifications fired elsewhere. + */ + +export interface Reminder { + /** Stable key. Reusing it reschedules in place; dropping it cancels. */ + key: string; + title: string; + body: string; + /** In-app path to open when tapped. */ + path: string; + /** Fire time, ms since epoch. */ + at: number; +} + +const REMINDER_IDS_KEY = 'skatespots:notifications:reminders'; + +type ReminderIdMap = Record; + +function loadIds(): ReminderIdMap { + try { + const raw = localStorage.getItem(REMINDER_IDS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object') return parsed as ReminderIdMap; + } catch { + // Corrupt/blocked storage — start clean. + } + return {}; +} + +function saveIds(map: ReminderIdMap): void { + try { + localStorage.setItem(REMINDER_IDS_KEY, JSON.stringify(map)); + } catch { + // Ignore — reminders still work for this session. + } +} + +async function cancelNative(ids: number[]): Promise { + if (ids.length === 0) return; + const { LocalNotifications } = await import('@capacitor/local-notifications'); + await LocalNotifications.cancel({ notifications: ids.map((id) => ({ id })) }); +} + +/** Cancel every reminder we scheduled (used when turning notifications off). */ +export async function cancelAllScheduledReminders(): Promise { + const ids = Object.values(loadIds()); + saveIds({}); + if (!isNativePlatform() || ids.length === 0) return; + try { + await cancelNative(ids); + } catch (err) { + console.warn('[reminders] cancel failed', err); + } +} + +/** + * Make the OS's pending reminders match `reminders` exactly: cancel the ones we + * previously scheduled that are no longer wanted, schedule the new ones, and + * leave already-scheduled ones alone (re-scheduling would only churn). + */ +export async function syncScheduledReminders(reminders: Reminder[]): Promise { + if (!isNativePlatform()) return; + + const now = Date.now(); + const desired = new Map(reminders.filter((r) => r.at > now).map((r) => [r.key, r])); + + // Don't touch the OS at all without permission. + const permission = await getNotificationPermission(); + if (permission !== 'granted') return; + + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const previous = loadIds(); + + const stale = Object.entries(previous) + .filter(([key]) => !desired.has(key)) + .map(([, id]) => id); + await cancelNative(stale); + + const next: ReminderIdMap = {}; + const toSchedule: LocalNotificationSchema[] = []; + for (const [key, reminder] of desired) { + const id = previous[key] ?? numericId(`reminder:${key}`); + next[key] = id; + if (previous[key] === undefined) { + toSchedule.push({ + id, + title: reminder.title, + body: reminder.body, + schedule: { at: new Date(reminder.at), allowWhileIdle: true }, + extra: { path: reminder.path }, + }); + } + } + + if (toSchedule.length > 0) { + await LocalNotifications.schedule({ notifications: toSchedule }); + } + saveIds(next); + } catch (err) { + console.warn('[reminders] sync failed', err); + } +}