{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-notification",
  "title": "useNotification",
  "description": "Sends desktop notifications behind a gesture-gated requestPermission() that never throws, keeps the permission state live via the Permissions API, registers each notification by tag so close(tag) works, and skips notifications while the page is visible.",
  "files": [
    {
      "path": "src/registry/hooks/use-notification.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * `Notification.permission` 的三个取值,外加一个 `\"unsupported\"`。\n *\n * `\"unsupported\"` 覆盖服务端渲染、以及**根本没有 Notification API** 的环境:非安全\n * 上下文(http:// 页面)、老浏览器、没被安装到主屏的 iOS Safari。它和 `\"denied\"`\n * 是两件事 —— 前者没有任何设置项可改,后者用户能在站点设置里放行。\n */\nexport type NotificationPermissionState = \"default\" | \"granted\" | \"denied\" | \"unsupported\"\n\n/**\n * `construct-failed` —— `new Notification()` **抛异常**了。这不是\"用户拒绝\",而是\n *                      \"这个引擎不给页面级通知\":Android Chrome 明确要求走\n *                      `ServiceWorkerRegistration.showNotification()`,部分 WebKit\n *                      构建暴露了构造函数却拒绝它。发生一次就把 `isSupported` 永久\n *                      翻成 `false`(见 hook 文档的能力探测一节)。\n * `error-event`      —— 通知**建出来了**,但平台派发了它自己的 `error` 事件(显示失败)。\n */\nexport type NotificationFailureReason = \"construct-failed\" | \"error-event\"\n\nexport interface NotificationFailure {\n  reason: NotificationFailureReason\n  /** `construct-failed` 时是抛出的值;`error-event` 时是那个 `Event`。 */\n  cause: unknown\n  /** 显示失败的那条通知;构造本身就抛了的话是 `null`。 */\n  notification: Notification | null\n}\n\nexport interface UseNotificationOptions {\n  /**\n   * 用户点了通知。桌面通知的默认动作**不是**回到页面 —— 想让点击把标签页拉到前台,\n   * 在这里自己调 `window.focus()`(通知点击算用户手势,桌面端允许)。\n   *\n   * 三个回调都走 latest-ref:每次渲染同步进 ref,不进任何依赖数组,所以消费者写\n   * 内联箭头函数也不会让监听反复拆装。\n   */\n  onClick?: (event: Event, notification: Notification) => void\n  /**\n   * 通知消失了。**只在\"不是你关的\"时候触发**:用户手动划掉、平台超时自动收起。\n   * 你自己调 `close()` 或被同 `tag` 的新通知替换掉时,监听会先摘再关,所以不会回调\n   * —— 于是 `onClose` 的语义干净地等于\"这条通知已经被用户处理掉了\"。\n   */\n  onClose?: (event: Event, notification: Notification) => void\n  /** 构造失败或平台报显示失败。**权限被拒不走这里**(那不是异常,见 `requestPermission`)。 */\n  onError?: (failure: NotificationFailure) => void\n  /**\n   * 页面当前可见时不发桌面通知,直接返回 `null`。默认 `true`。\n   *\n   * 理由:用户正盯着这个页面,再往系统通知中心塞一条是纯噪音 —— 该走页面内提示\n   * (`feedback/toast-stack`)。判断用 `document.visibilityState`,**在 `notify()` 被调用\n   * 的那一刻读**,不在渲染期读。\n   *\n   * 诚实边界:`visibilityState` 回答的是\"这个文档对用户不可见了吗\"(切标签页、最小化、\n   * 锁屏),**不等于窗口失焦** —— 把浏览器窗口拖到旁边、去点别的应用但页面仍露在屏幕上时,\n   * 多数桌面浏览器仍报 `visible`,于是通知照样会被跳过。要\"失焦即发\"就把这个关掉,\n   * 自己听 `window` 的 `blur`/`focus` 决定。\n   */\n  skipWhenVisible?: boolean\n  /**\n   * 卸载时关掉本实例创建的、还没消失的通知。默认 `true` —— 组件都没了,通知上的\n   * `onClick` 也跟着没了,留一条点了没反应的通知在系统通知中心是坏体验。\n   * 设成 `false` 可以让通知比组件活得久(比如路由切走后仍希望它留在通知中心)。\n   */\n  closeOnUnmount?: boolean\n}\n\nexport interface NotifyOptions extends NotificationOptions {\n  /** 覆盖 hook 级的 `skipWhenVisible`,只对这一条生效。 */\n  skipWhenVisible?: boolean\n}\n\nexport interface UseNotificationResult {\n  /** 实时权限。首帧(SSR 与水合)恒为 `\"unsupported\"`,水合后立刻换成真值。 */\n  permission: NotificationPermissionState\n  /** 这个环境能不能发页面级桌面通知。`false` 时 `notify()` 恒返回 `null`。 */\n  isSupported: boolean\n  /**\n   * 弹系统权限框。**必须从真实用户手势里调**(见 hook 文档)。\n   * 永不 reject:拒绝会 resolve 成 `\"denied\"`,不支持 resolve 成 `\"unsupported\"`。\n   */\n  requestPermission: () => Promise<NotificationPermissionState>\n  /** 发一条通知。返回创建出来的 `Notification`,没发成时返回 `null`(四种原因见文档)。 */\n  notify: (title: string, options?: NotifyOptions) => Notification | null\n  /** 关掉本实例创建的通知:给 `tag` 关那一条,不给参数关全部。 */\n  close: (tag?: string) => void\n}\n\ninterface LiveEntry {\n  notification: Notification\n  detach: () => void\n}\n\n/** 无 tag 的通知在表里用「`\\0` + 自增序号」当键:`\\0` 不可能出现在真实 tag 里,撞不了。 */\nconst SYNTHETIC_KEY_PREFIX = \"\\u0000\"\n\n/** 拿不到 Permissions API 时的兜底轮询间隔。属性读取而已,后台标签页里浏览器还会自动降频。 */\nconst POLL_INTERVAL_MS = 3000\n\n// ---------------------------------------------------------------------------\n// 模块级 store:整页只订阅一次权限变化,所有 hook 实例共享同一份真相。\n// ---------------------------------------------------------------------------\n\ntype StoreListener = () => void\n\nconst listeners = new Set<StoreListener>()\nlet permissionStatus: PermissionStatus | null = null\nlet statusHandler: StoreListener | null = null\nlet pollId: ReturnType<typeof setInterval> | null = null\nlet watchToken = 0\nlet lastSeenPermission: NotificationPermissionState = \"unsupported\"\nlet constructBroken = false\n\n/**\n * 唯一的权限读取口。**渲染期不许直接调**——它只作为 `useSyncExternalStore` 的\n * getSnapshot 出现,返回的是字符串原始值,所以不存在\"每次新对象导致无限重渲染\"。\n */\nfunction readPermission(): NotificationPermissionState {\n  if (typeof window === \"undefined\" || typeof window.Notification !== \"function\") return \"unsupported\"\n  return window.Notification.permission\n}\n\n/** 服务端(以及水合的第一帧)诚实地说\"这里发不了通知\"。 */\nconst getServerPermission = (): NotificationPermissionState => \"unsupported\"\n\nconst getConstructBroken = () => constructBroken\nconst getServerConstructBroken = () => false\n\nfunction emit() {\n  // 拷贝一份再遍历:listener 里退订(组件在回调中卸载)不会打乱本轮遍历。\n  for (const listener of [...listeners]) listener()\n}\n\n/** 值真的变了才通知 React,免得轮询每 3 秒空转一轮 re-render 检查。 */\nfunction syncPermission() {\n  const next = readPermission()\n  if (next === lastSeenPermission) return\n  lastSeenPermission = next\n  emit()\n}\n\nfunction startPolling() {\n  if (pollId !== null) return\n  pollId = setInterval(syncPermission, POLL_INTERVAL_MS)\n}\n\nfunction stopPolling() {\n  if (pollId === null) return\n  clearInterval(pollId)\n  pollId = null\n}\n\nfunction startWatching() {\n  lastSeenPermission = readPermission()\n  const token = ++watchToken\n  const permissions = typeof navigator === \"undefined\" ? undefined : navigator.permissions\n\n  if (!permissions || typeof permissions.query !== \"function\") {\n    // 没有 Permissions API(老 Safari、非安全上下文)→ 退回轮询。\n    startPolling()\n    return\n  }\n\n  // `query()` 在不认识这个 name 的引擎里会**同步抛**,而 `Promise.resolve(fn())`\n  // 拦不住同步抛出(异常在 Promise.resolve 拿到值之前就逃出去了)。executor 形式\n  // 会把同步抛错转成 rejection,消费者永远不会看到未处理的 rejection。\n  new Promise<PermissionStatus>(resolve => {\n    resolve(permissions.query({ name: \"notifications\" as PermissionName }))\n  }).then(\n    status => {\n      // 期间可能已经没人订阅了(或重新订阅过一轮),这份结果就作废。\n      if (token !== watchToken) return\n      permissionStatus = status\n      statusHandler = syncPermission\n      status.addEventListener(\"change\", statusHandler)\n      // 查询是异步的,等待期间用户可能已经改过设置 —— 挂上监听后补读一次。\n      syncPermission()\n    },\n    () => {\n      if (token !== watchToken) return\n      startPolling()\n    },\n  )\n}\n\nfunction stopWatching() {\n  // 先作废在途的 query,避免它晚到时给一个已经没人听的 status 挂上监听。\n  watchToken++\n  if (permissionStatus && statusHandler) permissionStatus.removeEventListener(\"change\", statusHandler)\n  permissionStatus = null\n  statusHandler = null\n  stopPolling()\n}\n\nfunction subscribe(listener: StoreListener) {\n  if (typeof window === \"undefined\") return () => {}\n  listeners.add(listener)\n  if (listeners.size === 1) startWatching()\n  return () => {\n    listeners.delete(listener)\n    if (listeners.size === 0) stopWatching()\n  }\n}\n\n/** 构造函数抛过一次 = 这个引擎不给页面级通知,整页所有实例一起翻成不支持。 */\nfunction markConstructBroken() {\n  if (constructBroken) return\n  constructBroken = true\n  emit()\n}\n\n/**\n * 桌面通知(Notification API):权限状态订阅 + 发通知 + 生命周期清理。\n *\n * **分工**(库里三件东西各管一段,别混):\n * - `hooks/use-permission` —— 只**查**权限(`navigator.permissions.query`),从不请求,\n *   适合在设置页一次列一排权限的当前状态。\n * - `feedback/permission-prompt` —— 请求**之前**那张\"为什么需要通知\"的解释卡 UI,\n *   它自己不碰任何权限 API,真正的请求由你在 `onRequest` 里交给本 hook。\n * - 本 hook —— 真的去请求、真的去**发**通知,并持有通知实例的生命周期。\n *\n * **`Notification.requestPermission()` 的四个坑,这里都处理了**:\n * 1. **它不 throw**。用户点\"阻止\"是 resolve 成 `\"denied\"`,不是异常路径 —— 所以\n *    `requestPermission()` 永不 reject,拒绝就是一个正常的返回值。\n * 2. **老 Safari(< 16)只支持回调式**,不返回 Promise,`await` 它会永远挂着。这里\n *    同时传回调、同时接 Promise,谁先到算谁,两种引擎都能拿到结果。\n * 3. **必须由真实用户手势触发**。没有手势时 Firefox 直接不弹框(停在 `\"default\"`),\n *    Chromium 会走\"安静通知\"甚至直接判 `\"denied\"` —— 而权限框每站只有一次,烧掉就\n *    再也要不回来。所以本 hook **绝不**在挂载时自动请求,`requestPermission()` 必须\n *    由你在 click 处理器里调。\n * 4. **拒过一次就不会再问**。再调 `requestPermission()` 会立刻 resolve `\"denied\"`、\n *    连框都不弹;此时 UI 该给的是\"去站点设置里改\"的步骤,不是一个\"重试\"按钮。\n *\n * **能力探测的诚实边界**:`window.Notification` 存在**不等于**能用 —— Android Chrome\n * 的 `new Notification()` 直接抛 `TypeError`(它只认 Service Worker 通知),部分 WebKit\n * 构建同理。而\"真的试着构造一次\"本身就是有副作用的(会真弹一条通知出来),所以这里\n * 不做假探测:`isSupported` 先按 API 是否存在回答,第一次 `notify()` 构造失败时把结论\n * 记进模块级 store,`isSupported` 立刻翻成 `false` 并触发一次 `onError`\n * (`reason: \"construct-failed\"`)。iOS Safari 在普通标签页里根本不暴露 Notification,\n * 只有被安装到主屏的 web app 才有,且那里只能走 Service Worker。\n *\n * **权限是会变的**:用户可以随时在站点设置里改。这里用 Permissions API 的\n * `notifications` + `change` 事件订阅;拿不到 Permissions API 就退回 3 秒轮询\n * (只是读一个属性),再不济 `requestPermission()` 返回时也会立刻刷新一次。整页只有\n * 一份订阅,所有实例共享。\n *\n * @example\n * const { permission, isSupported, requestPermission, notify, close } = useNotification({\n *   onClick: () => window.focus(),\n * })\n * // 必须在 click 处理器里:\n * <button onClick={() => void requestPermission()}>开启通知</button>\n * // 页面可见时默认不发(用户就在看着),切走后才发:\n * notify(\"构建完成\", { body: \"3 分 12 秒\", tag: \"build\" })\n */\nexport function useNotification(options: UseNotificationOptions = {}): UseNotificationResult {\n  const { onClick, onClose, onError, skipWhenVisible = true, closeOnUnmount = true } = options\n\n  const permission = React.useSyncExternalStore(subscribe, readPermission, getServerPermission)\n  const constructFailed = React.useSyncExternalStore(subscribe, getConstructBroken, getServerConstructBroken)\n  const isSupported = permission !== \"unsupported\" && !constructFailed\n\n  // latest-ref:回调与开关每次渲染同步进 ref,notify/close 因此可以是恒定引用。\n  const onClickRef = React.useRef(onClick)\n  const onCloseRef = React.useRef(onClose)\n  const onErrorRef = React.useRef(onError)\n  const skipWhenVisibleRef = React.useRef(skipWhenVisible)\n  const closeOnUnmountRef = React.useRef(closeOnUnmount)\n  React.useEffect(() => {\n    onClickRef.current = onClick\n    onCloseRef.current = onClose\n    onErrorRef.current = onError\n    skipWhenVisibleRef.current = skipWhenVisible\n    closeOnUnmountRef.current = closeOnUnmount\n  })\n\n  // tag → 实例表。懒建:`useRef(new Map())` 会在每次渲染都白造一个 Map。\n  const liveRef = React.useRef<Map<string, LiveEntry> | null>(null)\n  const syntheticKeyRef = React.useRef(0)\n  const requestRef = React.useRef<Promise<NotificationPermissionState> | null>(null)\n\n  const requestPermission = React.useCallback((): Promise<NotificationPermissionState> => {\n    if (typeof window === \"undefined\" || typeof window.Notification !== \"function\") {\n      return Promise.resolve<NotificationPermissionState>(\"unsupported\")\n    }\n    // 框已经开着的时候再点一次不该再要一次:引擎对第二次请求的处理各不相同,\n    // 复用同一个 promise 最省事也最可预测。\n    const inFlight = requestRef.current\n    if (inFlight) return inFlight\n\n    const request = new Promise<NotificationPermissionState>(resolve => {\n      let settled = false\n      const settle = (value?: NotificationPermission) => {\n        if (settled) return\n        settled = true\n        // 先把新值推进 store:`change` 事件不保证会来、更不保证够快,而调用方\n        // 一 resolve 就会去渲染新状态。\n        syncPermission()\n        resolve(value ?? readPermission())\n      }\n\n      let result: Promise<NotificationPermission> | undefined\n      try {\n        // 回调 + Promise 双保险:老 Safari 只走回调(返回 undefined),现代引擎\n        // 两条路都通,`settled` 保证只认第一个到达的结果。\n        result = window.Notification.requestPermission(settle) as Promise<NotificationPermission> | undefined\n      } catch {\n        // 个别老引擎在参数不合它意时会同步抛。不往外扔,按当前权限如实回答。\n        settle()\n        return\n      }\n      // rejection 里带的是错误对象、不是权限值,所以不能直接把 settle 当第二个参数。\n      result?.then(settle, () => settle())\n    })\n\n    requestRef.current = request\n    const clear = () => {\n      if (requestRef.current === request) requestRef.current = null\n    }\n    void request.then(clear, clear)\n    return request\n  }, [])\n\n  const notify = React.useCallback((title: string, notifyOptions?: NotifyOptions): Notification | null => {\n    if (typeof window === \"undefined\" || typeof window.Notification !== \"function\") return null\n    // 已知这个引擎构造会抛,就别再抛第二次了。\n    if (constructBroken) return null\n    // 读实时属性而不是渲染快照:用户可能刚在设置里改过,change 事件还在路上。\n    if (window.Notification.permission !== \"granted\") return null\n\n    const { skipWhenVisible: skipOverride, ...init } = notifyOptions ?? {}\n    const skip = skipOverride ?? skipWhenVisibleRef.current\n    // 只在这里(事件处理器里)读 document,渲染期一次都不读。\n    if (skip && typeof document !== \"undefined\" && document.visibilityState === \"visible\") return null\n\n    let notification: Notification\n    try {\n      notification = new window.Notification(title, init)\n    } catch (error) {\n      markConstructBroken()\n      onErrorRef.current?.({ reason: \"construct-failed\", cause: error, notification: null })\n      return null\n    }\n\n    const live = (liveRef.current ??= new Map<string, LiveEntry>())\n    // 空字符串 tag 在规范里等于\"没有 tag\",不该让所有无 tag 通知挤进同一个键。\n    const tag = typeof init.tag === \"string\" && init.tag.length > 0 ? init.tag : null\n    const key = tag ?? `${SYNTHETIC_KEY_PREFIX}${(syntheticKeyRef.current += 1)}`\n\n    function detach() {\n      notification.removeEventListener(\"click\", handleClick)\n      notification.removeEventListener(\"close\", handleClose)\n      notification.removeEventListener(\"error\", handleError)\n    }\n    function forget() {\n      detach()\n      // 只有表里存的还是\"我\"才删:同 tag 被新通知替换后表里已经是新实例,\n      // 旧实例迟到的 close 不能把新的那条抹掉。\n      if (live.get(key)?.notification === notification) live.delete(key)\n    }\n    function handleClick(event: Event) {\n      onClickRef.current?.(event, notification)\n    }\n    function handleClose(event: Event) {\n      forget()\n      onCloseRef.current?.(event, notification)\n    }\n    function handleError(event: Event) {\n      forget()\n      onErrorRef.current?.({ reason: \"error-event\", cause: event, notification })\n    }\n\n    // 同 tag 的新通知会**替换**旧的:旧实例已经不在屏幕上,先摘监听再移出表,\n    // 否则表里留着死实例,`close(tag)` 会关到错的那条、监听也泄漏。\n    const previous = live.get(key)\n    if (previous) {\n      previous.detach()\n      live.delete(key)\n    }\n\n    notification.addEventListener(\"click\", handleClick)\n    notification.addEventListener(\"close\", handleClose)\n    notification.addEventListener(\"error\", handleError)\n    live.set(key, { notification, detach })\n\n    return notification\n  }, [])\n\n  const close = React.useCallback((tag?: string) => {\n    const live = liveRef.current\n    if (!live || live.size === 0) return\n\n    if (tag === undefined) {\n      // 先摘监听再关:自己关掉的不该回调 onClose(见 onClose 的语义约定)。\n      for (const entry of [...live.values()]) {\n        entry.detach()\n        entry.notification.close()\n      }\n      live.clear()\n      return\n    }\n\n    const entry = live.get(tag)\n    if (!entry) return\n    live.delete(tag)\n    entry.detach()\n    entry.notification.close()\n  }, [])\n\n  React.useEffect(\n    () => () => {\n      const live = liveRef.current\n      if (!live) return\n      for (const entry of live.values()) {\n        entry.detach()\n        if (closeOnUnmountRef.current) entry.notification.close()\n      }\n      live.clear()\n    },\n    [],\n  )\n\n  return React.useMemo(\n    () => ({ permission, isSupported, requestPermission, notify, close }),\n    [permission, isSupported, requestPermission, notify, close],\n  )\n}\n\nexport default useNotification\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}