An OS alarm an hour before each upcoming event the user RSVP'd going or interested to. Because the OS holds the alarm it fires with the app fully closed — the only closed-app notification achievable without a server. - src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications, tracking handed-out ids in localStorage so withdrawn RSVPs and passed events are cancelled without disturbing one-off notifications. Web is a no-op. - src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and my-RSVPs queries; no new relay destination, no signing. - Bridge mounts the hook only on native and only when the new 'reminders' pref is on; unmount cancels everything (opt-out/logout). - Skipped the Pro-expiry reminder on purpose: it would drag the heavy useSasquatchPro query into a nudge. Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
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<string, number>;
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|