import { shallowRef } from 'vue'; import type { ComputedRef, ShallowRef } from 'vue'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { createEventHook } from '@/composables/utilities/createEventHook'; import type { EventHookOn } from '@/composables/utilities/createEventHook'; /** * Per-notification options mirroring the `Notification` constructor's * `NotificationOptions`, plus the `title` argument folded in for convenience. */ export interface WebNotificationOptions { /** * The title shown at the top of the notification. */ title?: string; /** * The body text displayed below the title. */ body?: string; /** * Text direction. * * @default 'auto' */ dir?: 'auto' | 'ltr' | 'rtl'; /** * BCP 47 language tag for the notification's content. */ lang?: string; /** * An identifying tag. Notifications sharing a tag replace one another. */ tag?: string; /** * URL of an icon to display. */ icon?: string; /** * Whether to re-alert the user when a notification replaces an older one * with the same `tag`. * * @default false */ renotify?: boolean; /** * Keep the notification active until the user interacts with it instead of * auto-dismissing. * * @default false */ requireInteraction?: boolean; /** * Whether the notification is silent (no sound/vibration). * * @default false */ silent?: boolean; /** * Vibration pattern (in milliseconds) for devices that support it. */ vibrate?: number[]; } export interface UseWebNotificationOptions extends WebNotificationOptions, ConfigurableWindow { /** * Request permission on mount (when supported and not yet granted). * * @default true */ requestPermissions?: boolean; } export interface UseWebNotificationReturn { /** * Whether the Notification API is supported in the current environment. */ isSupported: ComputedRef; /** * The currently displayed `Notification`, or `null` when none is shown. */ notification: ShallowRef; /** * Whether notification permission has been granted. */ permissionGranted: ShallowRef; /** * Display a notification, optionally overriding the default options for this * call. Resolves with the created `Notification`, or `undefined` when * unsupported or permission is not granted. */ show: (overrides?: WebNotificationOptions) => Promise; /** * Close the currently displayed notification (if any). */ close: () => void; /** * Request permission if it has not yet been granted (and was not denied). * Resolves with the resulting granted state, or `undefined` when unsupported. */ ensurePermissionGranted: () => Promise; /** * Register a listener fired when the notification is clicked. */ onClick: EventHookOn; /** * Register a listener fired when the notification is shown. */ onShow: EventHookOn; /** * Register a listener fired when the notification errors. */ onError: EventHookOn; /** * Register a listener fired when the notification is closed. */ onClose: EventHookOn; } /** * @name useWebNotification * @category Browser * @description Reactive, SSR-safe wrapper around the Web Notification API with * permission handling and `onClick`/`onShow`/`onError`/`onClose` event hooks. * * @param {UseWebNotificationOptions} [options={}] Default notification options plus behavior flags * @returns {UseWebNotificationReturn} `isSupported`, `notification`, `permissionGranted`, `show`, `close`, `ensurePermissionGranted`, and the `onClick`/`onShow`/`onError`/`onClose` hooks * * @example * const { show, isSupported, onClick } = useWebNotification({ * title: 'Hello', * body: 'You have a new message', * icon: '/icon.png', * }); * onClick(() => console.log('clicked')); * if (isSupported.value) * show(); * * @example * // Override options per call * const { show } = useWebNotification(); * show({ title: 'Override', body: 'Per-call body' }); * * @since 0.0.14 */ export function useWebNotification( options: UseWebNotificationOptions = {}, ): UseWebNotificationReturn { const { window = defaultWindow, requestPermissions = true, } = options; // The constructor lives on the resolved window so tests/iframes can supply // their own; falling back to a globally available `Notification` keeps the // common (no-window-override) case working. const getNotificationCtor = (): typeof Notification | undefined => { if (window && 'Notification' in window) return (window as Window & { Notification: typeof Notification }).Notification; return undefined; }; const isSupported = useSupported(() => { const Ctor = getNotificationCtor(); if (!Ctor) return false; // Already granted means the constructor is definitely usable. if (Ctor.permission === 'granted') return true; // Some environments expose `Notification` but throw on construction // (e.g. Android Chrome). Probe once to confirm it is truly usable. try { const probe = new Ctor(''); probe.onshow = () => probe.close(); } catch (error) { if ((error as Error).name === 'TypeError') return false; } return true; }); const notification = shallowRef(null); const permissionGranted = shallowRef( isSupported.value && getNotificationCtor()?.permission === 'granted', ); const { on: onClick, trigger: clickTrigger } = createEventHook(); const { on: onShow, trigger: showTrigger } = createEventHook(); const { on: onError, trigger: errorTrigger } = createEventHook(); const { on: onClose, trigger: closeTrigger } = createEventHook(); const ensurePermissionGranted = async (): Promise => { if (!isSupported.value) return undefined; const Ctor = getNotificationCtor()!; if (!permissionGranted.value && Ctor.permission !== 'denied') { const result = await Ctor.requestPermission(); if (result === 'granted') permissionGranted.value = true; } return permissionGranted.value; }; const close = (): void => { if (notification.value) notification.value.close(); notification.value = null; }; const show = async ( overrides?: WebNotificationOptions, ): Promise => { if (!isSupported.value || !permissionGranted.value) return undefined; const Ctor = getNotificationCtor()!; const merged: WebNotificationOptions = { ...options, ...overrides }; const created = new Ctor(merged.title ?? '', merged); created.onclick = clickTrigger; created.onshow = showTrigger; created.onerror = errorTrigger; created.onclose = closeTrigger; notification.value = created; return created; }; if (requestPermissions) tryOnMounted(ensurePermissionGranted); tryOnScopeDispose(close); // Close the active notification when the tab becomes visible again — the // user is already looking at the page, so the notification is redundant. if (window) { useEventListener( window.document, 'visibilitychange', () => { if (window.document.visibilityState === 'visible') close(); }, { passive: true }, ); } return { isSupported, notification, permissionGranted, show, close, ensurePermissionGranted, onClick, onShow, onError, onClose, }; }