SkateSpots/src/lib/osNotify.ts
SkateSpots d966bca148 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.
2026-09-29 14:12:55 +07:00

162 lines
5.3 KiB
TypeScript

import { isNativePlatform } from '@/lib/platform';
/**
* A single, platform-agnostic way to put something on the OS notification shade.
*
* - **Native (Android/iOS via Capacitor):** `@capacitor/local-notifications`.
* The plugin is imported LAZILY so the web bundle never pays for it.
* - **Web:** the Notifications API (`new Notification(...)`). This only works
* while the page is alive — a background browser tab is exactly the case it
* is for. A closed tab/app needs a server push, which this deliberately is not.
*
* Nothing here contacts the network. The only payload is text already computed
* on the device; no pubkey, no message content, no location is sent anywhere.
*/
export type OsPermission = 'granted' | 'denied' | 'prompt' | 'unsupported';
type NativeDisplay = 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
function mapNative(display: NativeDisplay): OsPermission {
if (display === 'granted') return 'granted';
if (display === 'denied') return 'denied';
return 'prompt';
}
function mapWeb(permission: NotificationPermission): OsPermission {
if (permission === 'granted') return 'granted';
if (permission === 'denied') return 'denied';
return 'prompt';
}
function webSupported(): boolean {
return typeof window !== 'undefined' && 'Notification' in window;
}
/** Current permission state, without prompting. */
export async function getNotificationPermission(): Promise<OsPermission> {
if (isNativePlatform()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const res = await LocalNotifications.checkPermissions();
return mapNative(res.display as NativeDisplay);
} catch (err) {
console.warn('[notify] LocalNotifications unavailable', err);
return 'unsupported';
}
}
if (!webSupported()) return 'unsupported';
return mapWeb(Notification.permission);
}
/** Ask the OS for permission. Must be called from a user gesture on the web. */
export async function requestNotificationPermission(): Promise<OsPermission> {
if (isNativePlatform()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const res = await LocalNotifications.requestPermissions();
return mapNative(res.display as NativeDisplay);
} catch (err) {
console.warn('[notify] LocalNotifications unavailable', err);
return 'unsupported';
}
}
if (!webSupported()) return 'unsupported';
try {
return mapWeb(await Notification.requestPermission());
} catch (err) {
console.warn('[notify] requestPermission failed', err);
return 'unsupported';
}
}
export interface OsNotificationInput {
/** Stable id — reused ids replace rather than stack on both platforms. */
id: string;
title: string;
body?: string;
/** In-app path to open when the notification is tapped, e.g. `/messages`. */
path?: string;
}
/** Stable 32-bit int for the native notification id (which must be numeric). */
export function numericId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
const n = Math.abs(hash) % 2147483647;
return n === 0 ? 1 : n;
}
/** Show an OS notification. Returns whether it was actually delivered. */
export async function showOsNotification(input: OsNotificationInput): Promise<boolean> {
if (isNativePlatform()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.schedule({
notifications: [
{
id: numericId(input.id),
title: input.title,
body: input.body ?? '',
extra: { path: input.path ?? '/' },
},
],
});
return true;
} catch (err) {
console.warn('[notify] schedule failed', err);
return false;
}
}
if (!webSupported() || Notification.permission !== 'granted') return false;
try {
const notification = new Notification(input.title, {
body: input.body,
tag: input.id,
icon: `${import.meta.env.BASE_URL}images/logo.png`,
data: { path: input.path ?? '/' },
});
notification.onclick = () => {
try {
window.focus();
if (input.path) window.location.hash = `#${input.path}`;
notification.close();
} catch {
// Ignore — the tap is best-effort.
}
};
return true;
} catch (err) {
console.warn('[notify] web Notification failed', err);
return false;
}
}
/**
* Subscribe to native notification taps. No-op on the web (where `onclick` is
* wired per notification instead). Returns an unsubscribe function.
*/
export async function listenForNotificationTaps(
handler: (path: string) => void,
): Promise<() => void> {
if (!isNativePlatform()) return () => {};
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const listener = await LocalNotifications.addListener(
'localNotificationActionPerformed',
(action) => {
const extra = action.notification.extra as { path?: string } | undefined;
if (extra?.path) handler(extra.path);
},
);
return () => {
void listener.remove();
};
} catch (err) {
console.warn('[notify] tap listener unavailable', err);
return () => {};
}
}