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 { 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 { 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 { 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 () => {}; } }