@flamingo-stack/openframe-frontend-core 0.0.262 → 0.0.263-snapshot.20260612110441

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/{chunk-C3FAOQAH.cjs → chunk-3LWUHLPK.cjs} +28 -28
  2. package/dist/{chunk-C3FAOQAH.cjs.map → chunk-3LWUHLPK.cjs.map} +1 -1
  3. package/dist/{chunk-U7UDESAU.js → chunk-6ZEKKAAL.js} +2 -2
  4. package/dist/{chunk-YC357BPF.js → chunk-JOUI6TQY.js} +3 -3
  5. package/dist/{chunk-2XUWR7AH.cjs → chunk-P67D545X.cjs} +11 -11
  6. package/dist/{chunk-2XUWR7AH.cjs.map → chunk-P67D545X.cjs.map} +1 -1
  7. package/dist/{chunk-3DC6MHW6.cjs → chunk-QLWBBCI5.cjs} +491 -391
  8. package/dist/chunk-QLWBBCI5.cjs.map +1 -0
  9. package/dist/{chunk-36HLBLDK.cjs → chunk-SIYHXLFH.cjs} +462 -454
  10. package/dist/chunk-SIYHXLFH.cjs.map +1 -0
  11. package/dist/{chunk-ALEKX56R.js → chunk-SSUUMPAQ.js} +2 -2
  12. package/dist/{chunk-6AIXHYBZ.js → chunk-VYTU7JZR.js} +64 -56
  13. package/dist/chunk-VYTU7JZR.js.map +1 -0
  14. package/dist/{chunk-6GBA6YJN.js → chunk-ZMFT5XOB.js} +576 -476
  15. package/dist/chunk-ZMFT5XOB.js.map +1 -0
  16. package/dist/{chunk-XKA7UFED.cjs → chunk-ZOOTBYZO.cjs} +30 -30
  17. package/dist/{chunk-XKA7UFED.cjs.map → chunk-ZOOTBYZO.cjs.map} +1 -1
  18. package/dist/components/chat/index.cjs +3 -3
  19. package/dist/components/chat/index.js +2 -2
  20. package/dist/components/contact/index.cjs +4 -4
  21. package/dist/components/contact/index.js +3 -3
  22. package/dist/components/features/index.cjs +3 -3
  23. package/dist/components/features/index.js +2 -2
  24. package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
  25. package/dist/components/features/notifications/notifications-context.d.ts +9 -1
  26. package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
  27. package/dist/components/index.cjs +55 -55
  28. package/dist/components/index.cjs.map +1 -1
  29. package/dist/components/index.js +9 -9
  30. package/dist/components/index.js.map +1 -1
  31. package/dist/components/navigation/index.cjs +3 -3
  32. package/dist/components/navigation/index.js +2 -2
  33. package/dist/components/navigation/sliding-sidebar.d.ts.map +1 -1
  34. package/dist/components/related-content/index.cjs +4 -4
  35. package/dist/components/related-content/index.js +3 -3
  36. package/dist/components/tickets/index.cjs +58 -58
  37. package/dist/components/tickets/index.js +4 -4
  38. package/dist/components/ui/drawer.d.ts +5 -0
  39. package/dist/components/ui/drawer.d.ts.map +1 -1
  40. package/dist/components/ui/index.cjs +3 -3
  41. package/dist/components/ui/index.js +2 -2
  42. package/dist/hooks/index.cjs +6 -2
  43. package/dist/hooks/index.cjs.map +1 -1
  44. package/dist/hooks/index.js +5 -1
  45. package/dist/hooks/ui/index.d.ts +6 -4
  46. package/dist/hooks/ui/index.d.ts.map +1 -1
  47. package/dist/hooks/ui/use-header-height.d.ts +8 -0
  48. package/dist/hooks/ui/use-header-height.d.ts.map +1 -0
  49. package/dist/hooks/ui/use-horizontal-scrollbar.d.ts.map +1 -1
  50. package/dist/hooks/ui/use-notification-permission.d.ts +14 -0
  51. package/dist/hooks/ui/use-notification-permission.d.ts.map +1 -0
  52. package/dist/index.cjs +7 -3
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.js +6 -2
  55. package/package.json +1 -1
  56. package/src/components/comment-card.tsx +1 -1
  57. package/src/components/features/notifications/notification-drawer.tsx +51 -2
  58. package/src/components/features/notifications/notifications-context.tsx +26 -0
  59. package/src/components/navigation/sliding-sidebar.tsx +3 -82
  60. package/src/components/ui/drawer.tsx +17 -6
  61. package/src/components/vendor-display-button.tsx +5 -5
  62. package/src/hooks/ui/index.ts +6 -4
  63. package/src/hooks/ui/use-header-height.ts +55 -0
  64. package/src/hooks/ui/use-horizontal-scrollbar.ts +19 -2
  65. package/src/hooks/ui/use-notification-permission.ts +64 -0
  66. package/dist/chunk-36HLBLDK.cjs.map +0 -1
  67. package/dist/chunk-3DC6MHW6.cjs.map +0 -1
  68. package/dist/chunk-6AIXHYBZ.js.map +0 -1
  69. package/dist/chunk-6GBA6YJN.js.map +0 -1
  70. /package/dist/{chunk-U7UDESAU.js.map → chunk-6ZEKKAAL.js.map} +0 -0
  71. /package/dist/{chunk-YC357BPF.js.map → chunk-JOUI6TQY.js.map} +0 -0
  72. /package/dist/{chunk-ALEKX56R.js.map → chunk-SSUUMPAQ.js.map} +0 -0
@@ -20,6 +20,11 @@ interface NotificationsContextValue {
20
20
  close: () => void
21
21
  toggle: () => void
22
22
  setShowPopups: (value: boolean) => void
23
+ /** OS-level notification preference; only meaningful when `desktopPopupsConfigured`. */
24
+ showDesktopPopups: boolean
25
+ setShowDesktopPopups: (value: boolean) => void
26
+ /** True when the host app wired desktop notifications (passed `onShowDesktopPopupsChange`). */
27
+ desktopPopupsConfigured: boolean
23
28
  onHistoryClick?: () => void
24
29
  hasMore: boolean
25
30
  isLoadingMore: boolean
@@ -46,6 +51,9 @@ export interface NotificationsProviderProps {
46
51
  maxNotifications?: number
47
52
  defaultShowPopups?: boolean
48
53
  onShowPopupsChange?: (value: boolean) => void
54
+ /** Desktop (OS-level) notification preference; providing `onShowDesktopPopupsChange` opts into the drawer toggle. */
55
+ defaultShowDesktopPopups?: boolean
56
+ onShowDesktopPopupsChange?: (value: boolean) => void
49
57
  onHistoryClick?: () => void
50
58
  actions?: NotificationsActions
51
59
  /** Pagination — when omitted, the drawer hides its load-more sentinel. */
@@ -138,6 +146,8 @@ export function NotificationsProvider({
138
146
  maxNotifications = 50,
139
147
  defaultShowPopups = true,
140
148
  onShowPopupsChange,
149
+ defaultShowDesktopPopups = false,
150
+ onShowDesktopPopupsChange,
141
151
  onHistoryClick,
142
152
  actions,
143
153
  hasMore = false,
@@ -148,6 +158,8 @@ export function NotificationsProvider({
148
158
  const [notifications, dispatch] = React.useReducer(reducer, initialNotifications)
149
159
  const [isOpen, setIsOpen] = React.useState(false)
150
160
  const [showPopups, setShowPopupsState] = React.useState(defaultShowPopups)
161
+ const [showDesktopPopups, setShowDesktopPopupsState] = React.useState(defaultShowDesktopPopups)
162
+ const desktopPopupsConfigured = onShowDesktopPopupsChange !== undefined
151
163
 
152
164
  const actionsRef = React.useRef(actions)
153
165
  React.useEffect(() => {
@@ -221,6 +233,14 @@ export function NotificationsProvider({
221
233
  [onShowPopupsChange],
222
234
  )
223
235
 
236
+ const setShowDesktopPopups = React.useCallback(
237
+ (value: boolean) => {
238
+ setShowDesktopPopupsState(value)
239
+ onShowDesktopPopupsChange?.(value)
240
+ },
241
+ [onShowDesktopPopupsChange],
242
+ )
243
+
224
244
  const unreadCount = React.useMemo(
225
245
  () => notifications.reduce((acc, n) => (n.read ? acc : acc + 1), 0),
226
246
  [notifications],
@@ -244,6 +264,9 @@ export function NotificationsProvider({
244
264
  close,
245
265
  toggle,
246
266
  setShowPopups,
267
+ showDesktopPopups,
268
+ setShowDesktopPopups,
269
+ desktopPopupsConfigured,
247
270
  onHistoryClick,
248
271
  hasMore,
249
272
  isLoadingMore,
@@ -255,6 +278,9 @@ export function NotificationsProvider({
255
278
  unreadCount,
256
279
  isOpen,
257
280
  showPopups,
281
+ showDesktopPopups,
282
+ setShowDesktopPopups,
283
+ desktopPopupsConfigured,
258
284
  addNotification,
259
285
  upsertNotification,
260
286
  setNotifications,
@@ -3,6 +3,7 @@
3
3
  import React, { useState, useEffect } from 'react'
4
4
  import { motion, AnimatePresence } from 'framer-motion'
5
5
  import { cn } from '../../utils'
6
+ import { useHeaderHeight } from '../../hooks/ui'
6
7
  import { SlidingSidebarConfig, NavigationItem } from '../../types/navigation'
7
8
  import { Button } from '../ui/button'
8
9
 
@@ -13,89 +14,9 @@ export interface SlidingSidebarProps {
13
14
  export function SlidingSidebar({ config }: SlidingSidebarProps) {
14
15
  const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
15
16
  const [mounted, setMounted] = useState(false)
16
- const [headerHeight, setHeaderHeight] = useState(64) // Default to 64px
17
+ const headerHeight = useHeaderHeight()
17
18
 
18
- useEffect(() => {
19
- setMounted(true)
20
-
21
- // Function to calculate total header height
22
- const calculateHeaderHeight = () => {
23
- let totalHeight = 0
24
-
25
- // Find and measure the header
26
- const header = document.querySelector('header')
27
- if (header) {
28
- totalHeight += header.offsetHeight
29
- }
30
-
31
- // Find and measure the announcement bar (if exists)
32
- const announcementBar = document.querySelector('[data-announcement-bar]')
33
- if (announcementBar && announcementBar instanceof HTMLElement) {
34
- totalHeight += announcementBar.offsetHeight
35
- }
36
-
37
- // Update the header height if we found elements
38
- if (totalHeight > 0) {
39
- setHeaderHeight(totalHeight)
40
- } else {
41
- // If no header found, default to 64px
42
- setHeaderHeight(64)
43
- }
44
- }
45
-
46
- // Calculate initial height with a small delay to ensure elements are rendered
47
- setTimeout(calculateHeaderHeight, 100)
48
-
49
- // Also calculate immediately in case elements are already there
50
- calculateHeaderHeight()
51
-
52
- // Recalculate on window resize
53
- window.addEventListener('resize', calculateHeaderHeight)
54
-
55
- // Use ResizeObserver to detect header size changes
56
- const resizeObserver = new ResizeObserver(calculateHeaderHeight)
57
-
58
- // Observe header
59
- const header = document.querySelector('header')
60
- if (header) {
61
- resizeObserver.observe(header)
62
- }
63
-
64
- // Observe announcement bar
65
- const announcementBar = document.querySelector('[data-announcement-bar]')
66
- if (announcementBar) {
67
- resizeObserver.observe(announcementBar)
68
- }
69
-
70
- // Use MutationObserver to detect when announcement bar is added/removed
71
- const mutationObserver = new MutationObserver((mutations) => {
72
- // Check if announcement bar was added or removed
73
- for (const mutation of mutations) {
74
- if (mutation.type === 'childList') {
75
- // Recalculate height when DOM changes
76
- calculateHeaderHeight()
77
-
78
- // If announcement bar was added, start observing it
79
- const newAnnouncementBar = document.querySelector('[data-announcement-bar]')
80
- if (newAnnouncementBar) {
81
- resizeObserver.observe(newAnnouncementBar)
82
- }
83
- }
84
- }
85
- })
86
-
87
- // Observe the body for changes (announcement bar might be added/removed)
88
- mutationObserver.observe(document.body, {
89
- childList: true,
90
- subtree: true
91
- })
92
-
93
- return () => {
94
- window.removeEventListener('resize', calculateHeaderHeight)
95
- resizeObserver.disconnect()
96
- mutationObserver.disconnect()
97
- }
98
- }, [])
19
+ useEffect(() => { setMounted(true) }, [])
99
20
 
100
21
  const toggleExpanded = (itemId: string) => {
101
22
  const newExpanded = new Set(expandedItems)
@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
6
6
  import { X } from "lucide-react"
7
7
 
8
8
  import { cn } from "../../utils/cn"
9
+ import { useHeaderHeight } from "../../hooks/ui/use-header-height"
9
10
 
10
11
  const Drawer = DialogPrimitive.Root
11
12
 
@@ -354,6 +355,9 @@ interface DrawerContentProps
354
355
  panelStyle?: React.CSSProperties
355
356
  /** Optional className applied to the inner panel. (Alias for `className`.) */
356
357
  panelClassName?: string
358
+ /** Offset the drawer's top edge below the app header + announcement bar.
359
+ * Measured live via ResizeObserver so it tracks height changes. */
360
+ offsetHeader?: boolean
357
361
  }
358
362
 
359
363
  const DrawerContent = React.forwardRef<
@@ -376,6 +380,7 @@ const DrawerContent = React.forwardRef<
376
380
  style,
377
381
  panelStyle,
378
382
  panelClassName,
383
+ offsetHeader = false,
379
384
  children,
380
385
  ...props
381
386
  },
@@ -385,6 +390,8 @@ const DrawerContent = React.forwardRef<
385
390
  const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide)
386
391
  const initialSize = defaultSize ?? (isHorizontal ? 560 : 480)
387
392
 
393
+ const headerHeight = useHeaderHeight()
394
+
388
395
  const [isMobile, setIsMobile] = React.useState(false)
389
396
  React.useEffect(() => {
390
397
  if (typeof window === "undefined") return
@@ -417,7 +424,7 @@ const DrawerContent = React.forwardRef<
417
424
  <DialogPrimitive.Content
418
425
  ref={ref}
419
426
  className={cn(drawerVariants({ side, flush }))}
420
- style={style}
427
+ style={{ ...(offsetHeader ? { top: headerHeight } : {}), ...style }}
421
428
  {...props}
422
429
  >
423
430
  {/* Resize handle is a sibling of the panel — outside the panel's
@@ -468,12 +475,14 @@ interface DrawerTitleProps
468
475
  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {
469
476
  /** Optional header actions rendered between the title and the close button. */
470
477
  actions?: React.ReactNode
478
+ /** Hide the close (X) button. */
479
+ hideClose?: boolean
471
480
  }
472
481
 
473
482
  const DrawerTitle = React.forwardRef<
474
483
  React.ComponentRef<typeof DialogPrimitive.Title>,
475
484
  DrawerTitleProps
476
- >(({ className, children, actions, ...props }, ref) => (
485
+ >(({ className, children, actions, hideClose, ...props }, ref) => (
477
486
  <div className="flex items-start gap-4">
478
487
  <DialogPrimitive.Title
479
488
  ref={ref}
@@ -486,10 +495,12 @@ const DrawerTitle = React.forwardRef<
486
495
  {children}
487
496
  </DialogPrimitive.Title>
488
497
  {actions}
489
- <DialogPrimitive.Close className="shrink-0 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0">
490
- <X className="size-6" />
491
- <span className="sr-only">Close</span>
492
- </DialogPrimitive.Close>
498
+ {!hideClose && (
499
+ <DialogPrimitive.Close className="shrink-0 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0">
500
+ <X className="size-6" />
501
+ <span className="sr-only">Close</span>
502
+ </DialogPrimitive.Close>
503
+ )}
493
504
  </div>
494
505
  ))
495
506
  DrawerTitle.displayName = "DrawerTitle"
@@ -31,7 +31,7 @@ export function VendorDisplayButton({ vendor, onClick, variant = 'default', exte
31
31
  return (
32
32
  <button
33
33
  onClick={handleClick}
34
- className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#1A1A1A] border border-[#2A2A2A] hover:border-[#FFC008]/50 transition-colors"
34
+ className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-ods-card border border-ods-border hover:border-ods-accent/50 transition-colors"
35
35
  >
36
36
  {logoUrl ? (
37
37
  <div className="w-5 h-5 rounded overflow-hidden flex-shrink-0">
@@ -44,13 +44,13 @@ export function VendorDisplayButton({ vendor, onClick, variant = 'default', exte
44
44
  />
45
45
  </div>
46
46
  ) : (
47
- <div className="w-5 h-5 rounded bg-[#2A2A2A] flex items-center justify-center flex-shrink-0">
48
- <span className="text-[#666666] text-[10px] font-medium">
47
+ <div className="w-5 h-5 rounded bg-ods-border flex items-center justify-center flex-shrink-0">
48
+ <span className="text-ods-text-secondary text-[10px] font-medium">
49
49
  {vendor.title.charAt(0).toUpperCase()}
50
50
  </span>
51
51
  </div>
52
52
  )}
53
- <span className="text-sm font-medium text-white">
53
+ <span className="text-sm font-medium text-ods-text-primary">
54
54
  {vendor.title}
55
55
  </span>
56
56
  </button>
@@ -61,7 +61,7 @@ export function VendorDisplayButton({ vendor, onClick, variant = 'default', exte
61
61
  return (
62
62
  <button
63
63
  onClick={handleClick}
64
- className="flex items-center gap-2 bg-ods-card border border-ods-border rounded-lg py-2 px-3 hover:border-[#FFC008] transition-colors max-w-full overflow-hidden"
64
+ className="flex items-center gap-2 bg-ods-card border border-ods-border rounded-lg py-2 px-3 hover:border-ods-accent transition-colors max-w-full overflow-hidden"
65
65
  >
66
66
  {getVendorLogo(vendor) ? (
67
67
  <div className="w-8 h-8 bg-ods-card border border-ods-border rounded-lg flex items-center justify-center flex-shrink-0">
@@ -1,13 +1,15 @@
1
1
  // UI Hooks exports
2
+ export * from './use-auto-limit-tags'
2
3
  export * from './use-debounce'
4
+ export * from './use-header-height'
5
+ export * from './use-horizontal-scrollbar'
6
+ export * from './use-image-edge-color'
3
7
  export * from './use-local-storage'
4
8
  export * from './use-media-query'
5
9
  export * from './use-memoized-callback'
10
+ export * from './use-notification-permission'
6
11
  export * from './use-onboarding-state'
12
+ export * from './use-search'
7
13
  export * from './use-table-pagination'
8
14
  export * from './use-throttle'
9
15
  export * from './use-window-size'
10
- export * from './use-horizontal-scrollbar'
11
- export * from './use-image-edge-color'
12
- export * from './use-search'
13
- export * from './use-auto-limit-tags'
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ /**
6
+ * Returns the combined height (px) of the sticky page header and announcement
7
+ * bar (if present), updated live via ResizeObserver / MutationObserver.
8
+ * Useful for offsetting fixed/absolute-positioned panels so they don't
9
+ * overlap the header.
10
+ */
11
+ export function useHeaderHeight(defaultHeight = 64): number {
12
+ const [height, setHeight] = useState(defaultHeight)
13
+
14
+ useEffect(() => {
15
+ const measure = () => {
16
+ let total = 0
17
+ const header = document.querySelector('header')
18
+ if (header) total += header.offsetHeight
19
+ const bar = document.querySelector('[data-announcement-bar]')
20
+ if (bar instanceof HTMLElement) total += bar.offsetHeight
21
+ setHeight(total > 0 ? total : defaultHeight)
22
+ }
23
+
24
+ measure()
25
+
26
+ const resizeObserver = new ResizeObserver(measure)
27
+ const header = document.querySelector('header')
28
+ if (header) resizeObserver.observe(header)
29
+ const bar = document.querySelector('[data-announcement-bar]')
30
+ if (bar) resizeObserver.observe(bar)
31
+
32
+ const mutationObserver = new MutationObserver((mutations) => {
33
+ for (const mutation of mutations) {
34
+ if (mutation.type === 'childList' || mutation.type === 'attributes') {
35
+ measure()
36
+ const newBar = document.querySelector('[data-announcement-bar]')
37
+ if (newBar) resizeObserver.observe(newBar)
38
+ }
39
+ }
40
+ })
41
+ mutationObserver.observe(document.body, {
42
+ childList: true,
43
+ subtree: true,
44
+ attributes: true,
45
+ attributeFilter: ['data-announcement-bar'],
46
+ })
47
+
48
+ return () => {
49
+ resizeObserver.disconnect()
50
+ mutationObserver.disconnect()
51
+ }
52
+ }, [defaultHeight])
53
+
54
+ return height
55
+ }
@@ -17,6 +17,7 @@ export function useHorizontalScrollbar() {
17
17
  const trackRef = useRef<HTMLDivElement>(null)
18
18
  const thumbRef = useRef<HTMLDivElement>(null)
19
19
  const roRef = useRef<ResizeObserver | null>(null)
20
+ const moRef = useRef<MutationObserver | null>(null)
20
21
  const [thumbRatio, setThumbRatio] = useState(0)
21
22
 
22
23
  // Edge-fade state
@@ -94,6 +95,10 @@ export function useHorizontalScrollbar() {
94
95
  roRef.current.disconnect()
95
96
  roRef.current = null
96
97
  }
98
+ if (moRef.current) {
99
+ moRef.current.disconnect()
100
+ moRef.current = null
101
+ }
97
102
  if (rafIdRef.current) {
98
103
  cancelAnimationFrame(rafIdRef.current)
99
104
  rafIdRef.current = 0
@@ -105,8 +110,20 @@ export function useHorizontalScrollbar() {
105
110
  const ratio = node.clientWidth / node.scrollWidth
106
111
  setThumbRatio(ratio >= 1 ? 0 : ratio)
107
112
 
108
- roRef.current = new ResizeObserver(measure)
109
- roRef.current.observe(node)
113
+ const ro = new ResizeObserver(measure)
114
+ roRef.current = ro
115
+ const observeAll = () => {
116
+ ro.disconnect()
117
+ ro.observe(node)
118
+ for (const child of Array.from(node.children)) ro.observe(child)
119
+ }
120
+ observeAll()
121
+
122
+ moRef.current = new MutationObserver(() => {
123
+ observeAll()
124
+ measure()
125
+ })
126
+ moRef.current.observe(node, { childList: true })
110
127
 
111
128
  requestAnimationFrame(() => {
112
129
  syncThumbToDOM()
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ export interface UseNotificationPermissionResult {
6
+ /** Whether the page-context Notification API exists in this browser (false during SSR and on iOS Safari). */
7
+ supported: boolean
8
+ permission: NotificationPermission
9
+ /** Prompt the user for permission. Must be called from a user gesture or browsers will auto-deny. */
10
+ request: () => Promise<NotificationPermission>
11
+ }
12
+
13
+ /**
14
+ * Tracks the browser's desktop-notification permission, staying in sync when
15
+ * the user changes the site setting externally (Permissions API change event
16
+ * where available, visibility/focus re-read as the Safari fallback).
17
+ */
18
+ export function useNotificationPermission(): UseNotificationPermissionResult {
19
+ const [supported, setSupported] = useState(false)
20
+ const [permission, setPermission] = useState<NotificationPermission>('default')
21
+
22
+ useEffect(() => {
23
+ if (typeof window === 'undefined' || !('Notification' in window)) return
24
+ setSupported(true)
25
+ const sync = () => setPermission(Notification.permission)
26
+ sync()
27
+
28
+ // The query resolves async; without the flag it would attach the listener after unmount.
29
+ let cancelled = false
30
+ let status: PermissionStatus | undefined
31
+ navigator.permissions
32
+ ?.query({ name: 'notifications' as PermissionName })
33
+ .then((s) => {
34
+ if (cancelled) return
35
+ status = s
36
+ s.addEventListener('change', sync)
37
+ })
38
+ .catch(() => undefined)
39
+
40
+ document.addEventListener('visibilitychange', sync)
41
+ window.addEventListener('focus', sync)
42
+ return () => {
43
+ cancelled = true
44
+ status?.removeEventListener('change', sync)
45
+ document.removeEventListener('visibilitychange', sync)
46
+ window.removeEventListener('focus', sync)
47
+ }
48
+ }, [])
49
+
50
+ const request = useCallback(async () => {
51
+ if (typeof window === 'undefined' || !('Notification' in window)) {
52
+ return 'denied' as NotificationPermission
53
+ }
54
+ // Older Safari only implements the callback form; resolving from both is harmless.
55
+ const result = await new Promise<NotificationPermission>((resolve) => {
56
+ const maybePromise = Notification.requestPermission(resolve)
57
+ if (maybePromise && typeof maybePromise.then === 'function') void maybePromise.then(resolve)
58
+ })
59
+ setPermission(Notification.permission)
60
+ return result
61
+ }, [])
62
+
63
+ return { supported, permission, request }
64
+ }