feat(notify): scheduled event reminders (Phase 1b) + v1.5.7
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.
This commit is contained in:
parent
a4b79adadd
commit
d966bca148
9 changed files with 257 additions and 5 deletions
|
|
@ -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
|
opening the app never replays history as a flood; **(3) suppressed while the
|
||||||
document is visible** (the bell already covers that). Delivery is **local
|
document is visible** (the bell already covers that). Delivery is **local
|
||||||
only** — a fully closed app/browser is unreachable without a server push.
|
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,
|
**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
|
~15 min floor) and a content-free FCM "doorbell" (timely, but a server learns
|
||||||
metadata). `useResolvedDmRelays` is exported for the envelope hook.
|
metadata). `useResolvedDmRelays` is exported for the envelope hook.
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.org",
|
"homepage": "https://skate-spots.org",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
v1.5.6
|
||||||
|
|
||||||
🛹 The map is quicker off the mark
|
🛹 The map is quicker off the mark
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||||
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
||||||
import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes';
|
import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes';
|
||||||
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
||||||
|
import { useLocalReminders } from '@/hooks/useLocalReminders';
|
||||||
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
import {
|
import {
|
||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
listenForNotificationTaps,
|
listenForNotificationTaps,
|
||||||
|
|
@ -331,5 +333,13 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
|
||||||
prefs,
|
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() ? <LocalReminders /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalReminders() {
|
||||||
|
useLocalReminders();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
src/hooks/useLocalReminders.ts
Normal file
101
src/hooks/useLocalReminders.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<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]);
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* nothing is requested from the OS until the user turns them on in Settings.
|
* 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 {
|
export interface NotificationPrefs {
|
||||||
/** Master switch. Off until the user explicitly enables notifications. */
|
/** Master switch. Off until the user explicitly enables notifications. */
|
||||||
|
|
@ -22,6 +22,8 @@ export interface NotificationPrefs {
|
||||||
social: boolean;
|
social: boolean;
|
||||||
/** New SkateSpots newsletter drops (subscribers only). */
|
/** New SkateSpots newsletter drops (subscribers only). */
|
||||||
newsletter: boolean;
|
newsletter: boolean;
|
||||||
|
/** Scheduled event reminders (Android app — fires even when the app is closed). */
|
||||||
|
reminders: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs';
|
export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs';
|
||||||
|
|
@ -32,6 +34,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||||
crew: true,
|
crew: true,
|
||||||
social: true,
|
social: true,
|
||||||
newsletter: true,
|
newsletter: true,
|
||||||
|
reminders: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Display metadata for the per-category toggles, in order. */
|
/** 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: '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: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' },
|
||||||
{ key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' },
|
{ 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)" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export interface OsNotificationInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable 32-bit int for the native notification id (which must be numeric). */
|
/** 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;
|
let hash = 0;
|
||||||
for (let i = 0; i < id.length; i++) {
|
for (let i = 0; i < id.length; i++) {
|
||||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||||
|
|
|
||||||
119
src/lib/reminders.ts
Normal file
119
src/lib/reminders.ts
Normal file
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue