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.
162 lines
5.3 KiB
TypeScript
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 () => {};
|
|
}
|
|
}
|