@eifi1/ui-kit 0.34.0 → 0.36.0

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 (446) hide show
  1. package/README.md +115 -41
  2. package/dist/admin/role-select.d.ts +3 -2
  3. package/dist/admin/role-select.js +2 -0
  4. package/dist/admin/role-select.js.map +1 -1
  5. package/dist/billing/billing-banner.d.ts +5 -2
  6. package/dist/billing/billing-banner.js.map +1 -1
  7. package/dist/billing/plan-limit-notice.d.ts +5 -2
  8. package/dist/billing/plan-limit-notice.js.map +1 -1
  9. package/dist/chart.js.map +1 -1
  10. package/dist/components/amount-input.d.ts +2 -5
  11. package/dist/components/amount-input.js +2 -2
  12. package/dist/components/amount-input.js.map +1 -1
  13. package/dist/components/autocomplete.d.ts +2 -2
  14. package/dist/components/autocomplete.js +2 -1
  15. package/dist/components/autocomplete.js.map +1 -1
  16. package/dist/components/button-look.d.ts +1 -1
  17. package/dist/components/button-look.js +9 -4
  18. package/dist/components/button-look.js.map +1 -1
  19. package/dist/components/calculator.d.ts +1 -4
  20. package/dist/components/calculator.js +1 -3
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/chart-zoom.d.ts +0 -37
  23. package/dist/components/chart-zoom.js +1 -28
  24. package/dist/components/chart-zoom.js.map +1 -1
  25. package/dist/components/checkbox-group.d.ts +5 -2
  26. package/dist/components/checkbox-group.js +15 -4
  27. package/dist/components/checkbox-group.js.map +1 -1
  28. package/dist/components/chip-input.d.ts +0 -4
  29. package/dist/components/chip-input.js +1 -5
  30. package/dist/components/chip-input.js.map +1 -1
  31. package/dist/components/column-mapper.d.ts +5 -3
  32. package/dist/components/column-mapper.js +2 -0
  33. package/dist/components/column-mapper.js.map +1 -1
  34. package/dist/components/combobox.d.ts +3 -3
  35. package/dist/components/combobox.js +2 -1
  36. package/dist/components/combobox.js.map +1 -1
  37. package/dist/components/copy-button.d.ts +5 -1
  38. package/dist/components/copy-button.js.map +1 -1
  39. package/dist/components/country-select.d.ts +3 -3
  40. package/dist/components/country-select.js +3 -0
  41. package/dist/components/country-select.js.map +1 -1
  42. package/dist/components/currency-option-list.d.ts +3 -8
  43. package/dist/components/currency-option-list.js +1 -29
  44. package/dist/components/currency-option-list.js.map +1 -1
  45. package/dist/components/currency-select.d.ts +3 -2
  46. package/dist/components/currency-select.js +10 -1
  47. package/dist/components/currency-select.js.map +1 -1
  48. package/dist/components/danger-confirm.d.ts +3 -3
  49. package/dist/components/danger-confirm.js.map +1 -1
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filter-popover.js +1 -1
  52. package/dist/components/data-table-filter-popover.js.map +1 -1
  53. package/dist/components/data-table-labels.d.ts +1 -42
  54. package/dist/components/data-table-labels.js +2 -31
  55. package/dist/components/data-table-labels.js.map +1 -1
  56. package/dist/components/data-table-shared.d.ts +17 -1
  57. package/dist/components/data-table-shared.js +22 -0
  58. package/dist/components/data-table-shared.js.map +1 -1
  59. package/dist/components/data-table.d.ts +12 -2
  60. package/dist/components/data-table.js +18 -14
  61. package/dist/components/data-table.js.map +1 -1
  62. package/dist/components/date-field.d.ts +2 -1
  63. package/dist/components/date-field.js.map +1 -1
  64. package/dist/components/date-picker.js +3 -0
  65. package/dist/components/date-picker.js.map +1 -1
  66. package/dist/components/date-range-picker.js +3 -0
  67. package/dist/components/date-range-picker.js.map +1 -1
  68. package/dist/components/dialog-frame.d.ts +3 -1
  69. package/dist/components/dialog-frame.js.map +1 -1
  70. package/dist/components/dropdown.d.ts +18 -15
  71. package/dist/components/dropdown.js +11 -3
  72. package/dist/components/dropdown.js.map +1 -1
  73. package/dist/components/entity-combobox.d.ts +3 -3
  74. package/dist/components/entity-combobox.js +2 -1
  75. package/dist/components/entity-combobox.js.map +1 -1
  76. package/dist/components/field-base.d.ts +0 -5
  77. package/dist/components/field-base.js +0 -5
  78. package/dist/components/field-base.js.map +1 -1
  79. package/dist/components/field-parts.d.ts +69 -22
  80. package/dist/components/field-parts.js +33 -19
  81. package/dist/components/field-parts.js.map +1 -1
  82. package/dist/components/field-strip.d.ts +9 -23
  83. package/dist/components/field-strip.js +26 -23
  84. package/dist/components/field-strip.js.map +1 -1
  85. package/dist/components/field-sync.d.ts +0 -4
  86. package/dist/components/field-sync.js +0 -4
  87. package/dist/components/field-sync.js.map +1 -1
  88. package/dist/components/field.d.ts +7 -6
  89. package/dist/components/field.js +24 -15
  90. package/dist/components/field.js.map +1 -1
  91. package/dist/components/full-bleed-dialog.js +2 -2
  92. package/dist/components/full-bleed-dialog.js.map +1 -1
  93. package/dist/components/help-popover.d.ts +9 -7
  94. package/dist/components/help-popover.js.map +1 -1
  95. package/dist/components/hover-menu.d.ts +6 -13
  96. package/dist/components/hover-menu.js +3 -3
  97. package/dist/components/hover-menu.js.map +1 -1
  98. package/dist/components/icon-picker.d.ts +3 -2
  99. package/dist/components/icon-picker.js +3 -0
  100. package/dist/components/icon-picker.js.map +1 -1
  101. package/dist/components/inline-entity-combobox.d.ts +3 -3
  102. package/dist/components/inline-entity-combobox.js +2 -1
  103. package/dist/components/inline-entity-combobox.js.map +1 -1
  104. package/dist/components/input.d.ts +2 -2
  105. package/dist/components/input.js +2 -1
  106. package/dist/components/input.js.map +1 -1
  107. package/dist/components/modal.d.ts +9 -1
  108. package/dist/components/modal.js +7 -4
  109. package/dist/components/modal.js.map +1 -1
  110. package/dist/components/month-picker.d.ts +2 -1
  111. package/dist/components/month-picker.js +8 -1
  112. package/dist/components/month-picker.js.map +1 -1
  113. package/dist/components/multi-entity-combobox.d.ts +2 -2
  114. package/dist/components/multi-entity-combobox.js +2 -1
  115. package/dist/components/multi-entity-combobox.js.map +1 -1
  116. package/dist/components/multi-select.d.ts +7 -18
  117. package/dist/components/multi-select.js +8 -10
  118. package/dist/components/multi-select.js.map +1 -1
  119. package/dist/components/number-input.d.ts +2 -5
  120. package/dist/components/number-input.js +2 -4
  121. package/dist/components/number-input.js.map +1 -1
  122. package/dist/components/numeric-field.d.ts +5 -2
  123. package/dist/components/numeric-field.js +4 -1
  124. package/dist/components/numeric-field.js.map +1 -1
  125. package/dist/components/one-time-code-input.d.ts +2 -1
  126. package/dist/components/one-time-code-input.js +2 -1
  127. package/dist/components/one-time-code-input.js.map +1 -1
  128. package/dist/components/phone-input.d.ts +1 -1
  129. package/dist/components/phone-input.js +4 -0
  130. package/dist/components/phone-input.js.map +1 -1
  131. package/dist/components/popover.js +3 -3
  132. package/dist/components/popover.js.map +1 -1
  133. package/dist/components/select.d.ts +4 -3
  134. package/dist/components/select.js +2 -1
  135. package/dist/components/select.js.map +1 -1
  136. package/dist/components/series-chart.js.map +1 -1
  137. package/dist/components/server-wake.d.ts +0 -6
  138. package/dist/components/server-wake.js.map +1 -1
  139. package/dist/components/settings-fields.d.ts +4 -2
  140. package/dist/components/settings-fields.js.map +1 -1
  141. package/dist/components/slider.d.ts +3 -2
  142. package/dist/components/slider.js +7 -1
  143. package/dist/components/slider.js.map +1 -1
  144. package/dist/components/stat-tile.d.ts +8 -2
  145. package/dist/components/stat-tile.js +2 -11
  146. package/dist/components/stat-tile.js.map +1 -1
  147. package/dist/components/swatch-picker.d.ts +3 -2
  148. package/dist/components/swatch-picker.js +3 -0
  149. package/dist/components/swatch-picker.js.map +1 -1
  150. package/dist/components/swipeable-row.d.ts +2 -17
  151. package/dist/components/swipeable-row.js +11 -16
  152. package/dist/components/swipeable-row.js.map +1 -1
  153. package/dist/components/text-link.js +3 -1
  154. package/dist/components/text-link.js.map +1 -1
  155. package/dist/components/textarea.d.ts +2 -1
  156. package/dist/components/textarea.js +9 -3
  157. package/dist/components/textarea.js.map +1 -1
  158. package/dist/components/toggle-group.d.ts +2 -2
  159. package/dist/components/toggle-group.js +7 -3
  160. package/dist/components/toggle-group.js.map +1 -1
  161. package/dist/components/tooltip.d.ts +9 -1
  162. package/dist/components/tooltip.js +20 -5
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/translation-review.d.ts +10 -2
  165. package/dist/components/translation-review.js +11 -3
  166. package/dist/components/translation-review.js.map +1 -1
  167. package/dist/components/use-table-state.d.ts +31 -3
  168. package/dist/components/use-table-state.js +53 -8
  169. package/dist/components/use-table-state.js.map +1 -1
  170. package/dist/components/user-avatar.d.ts +2 -2
  171. package/dist/components/user-avatar.js.map +1 -1
  172. package/dist/components/wizard-stepper.d.ts +1 -4
  173. package/dist/components/wizard-stepper.js +2 -3
  174. package/dist/components/wizard-stepper.js.map +1 -1
  175. package/dist/currencies.d.ts +23 -0
  176. package/dist/currencies.js +38 -0
  177. package/dist/currencies.js.map +1 -0
  178. package/dist/demo/demo-banner.d.ts +7 -2
  179. package/dist/demo/demo-banner.js.map +1 -1
  180. package/dist/eslint/index.d.ts +83 -0
  181. package/dist/eslint/index.js +69 -0
  182. package/dist/eslint/index.js.map +1 -0
  183. package/dist/eslint/role-utilities.d.ts +48 -0
  184. package/dist/eslint/role-utilities.generated.d.ts +2 -0
  185. package/dist/eslint/role-utilities.generated.js +143 -0
  186. package/dist/eslint/role-utilities.generated.js.map +1 -0
  187. package/dist/eslint/role-utilities.js +43 -0
  188. package/dist/eslint/role-utilities.js.map +1 -0
  189. package/dist/feedback/feedback-menu.d.ts +2 -2
  190. package/dist/feedback/feedback-menu.js.map +1 -1
  191. package/dist/feedback/feedback-thread.d.ts +3 -8
  192. package/dist/feedback/feedback-thread.js +0 -2
  193. package/dist/feedback/feedback-thread.js.map +1 -1
  194. package/dist/hooks/use-dismiss.d.ts +16 -3
  195. package/dist/hooks/use-dismiss.js +14 -2
  196. package/dist/hooks/use-dismiss.js.map +1 -1
  197. package/dist/i18n/defaults.js +5 -1
  198. package/dist/i18n/defaults.js.map +1 -1
  199. package/dist/i18n/german.js +97 -1
  200. package/dist/i18n/german.js.map +1 -1
  201. package/dist/i18n/kit-labels.d.ts +29 -4
  202. package/dist/i18n/kit-labels.js +21 -1
  203. package/dist/i18n/kit-labels.js.map +1 -1
  204. package/dist/i18n/locales/en.js +104 -3
  205. package/dist/i18n/locales/en.js.map +3 -3
  206. package/dist/i18n/locales/es.js +95 -1
  207. package/dist/i18n/locales/es.js.map +1 -1
  208. package/dist/i18n/locales/fr.js +99 -3
  209. package/dist/i18n/locales/fr.js.map +1 -1
  210. package/dist/i18n/locales/hu.js +95 -1
  211. package/dist/i18n/locales/hu.js.map +1 -1
  212. package/dist/i18n/locales/it.js +99 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.js +95 -1
  215. package/dist/i18n/locales/zh.js.map +1 -1
  216. package/dist/i18n/review.js +13 -0
  217. package/dist/i18n/review.js.map +1 -1
  218. package/dist/i18n/shared-labels.js +3 -1
  219. package/dist/i18n/shared-labels.js.map +1 -1
  220. package/dist/index.d.ts +23 -1
  221. package/dist/index.js +40 -2
  222. package/dist/index.js.map +1 -1
  223. package/dist/lib/device-settings.d.ts +72 -0
  224. package/dist/lib/device-settings.js +64 -0
  225. package/dist/lib/device-settings.js.map +1 -0
  226. package/dist/lib/plain-text.d.ts +11 -0
  227. package/dist/lib/plain-text.js +16 -0
  228. package/dist/lib/plain-text.js.map +1 -0
  229. package/dist/lib/refs.d.ts +13 -0
  230. package/dist/lib/refs.js +15 -0
  231. package/dist/lib/refs.js.map +1 -0
  232. package/dist/lib/safe-storage.d.ts +6 -0
  233. package/dist/lib/safe-storage.js +11 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/notifications/notification-parts.d.ts +39 -0
  236. package/dist/notifications/notification-parts.js +47 -0
  237. package/dist/notifications/notification-parts.js.map +1 -0
  238. package/dist/notifications/notification-types-setting.d.ts +62 -0
  239. package/dist/notifications/notification-types-setting.js +192 -0
  240. package/dist/notifications/notification-types-setting.js.map +1 -0
  241. package/dist/notifications/notification-types.d.ts +75 -0
  242. package/dist/notifications/notification-types.js +2 -0
  243. package/dist/notifications/notification-types.js.map +1 -0
  244. package/dist/notifications/notifications-labels.d.ts +131 -0
  245. package/dist/notifications/notifications-labels.js +83 -0
  246. package/dist/notifications/notifications-labels.js.map +1 -0
  247. package/dist/notifications/push-device-setting.d.ts +37 -0
  248. package/dist/notifications/push-device-setting.js +164 -0
  249. package/dist/notifications/push-device-setting.js.map +1 -0
  250. package/dist/notifications/push-devices-setting.d.ts +44 -0
  251. package/dist/notifications/push-devices-setting.js +203 -0
  252. package/dist/notifications/push-devices-setting.js.map +1 -0
  253. package/dist/notifications/quiet-hours-setting.d.ts +47 -0
  254. package/dist/notifications/quiet-hours-setting.js +131 -0
  255. package/dist/notifications/quiet-hours-setting.js.map +1 -0
  256. package/dist/notifications/time-zone-select.d.ts +50 -0
  257. package/dist/notifications/time-zone-select.js +76 -0
  258. package/dist/notifications/time-zone-select.js.map +1 -0
  259. package/dist/notifications/unsubscribe-panel.d.ts +53 -0
  260. package/dist/notifications/unsubscribe-panel.js +139 -0
  261. package/dist/notifications/unsubscribe-panel.js.map +1 -0
  262. package/dist/notifications/use-push-device.d.ts +122 -0
  263. package/dist/notifications/use-push-device.js +230 -0
  264. package/dist/notifications/use-push-device.js.map +1 -0
  265. package/dist/rhf/fields.d.ts +36 -29
  266. package/dist/rhf/fields.js +51 -22
  267. package/dist/rhf/fields.js.map +1 -1
  268. package/dist/rhf/form-core.d.ts +5 -4
  269. package/dist/rhf/form-core.js.map +1 -1
  270. package/dist/rhf/form.d.ts +7 -36
  271. package/dist/rhf/form.js +1 -16
  272. package/dist/rhf/form.js.map +1 -1
  273. package/dist/rhf/line-items.d.ts +1 -1
  274. package/dist/rhf/line-items.js.map +1 -1
  275. package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
  276. package/dist/rhf.js.map +1 -1
  277. package/dist/settings/device-reset-setting-labels.d.ts +3 -0
  278. package/dist/settings/device-reset-setting-labels.js +15 -0
  279. package/dist/settings/device-reset-setting-labels.js.map +1 -0
  280. package/dist/settings/device-reset-setting.d.ts +58 -0
  281. package/dist/settings/device-reset-setting.js +51 -0
  282. package/dist/settings/device-reset-setting.js.map +1 -0
  283. package/dist/shell/app-shell.js +5 -0
  284. package/dist/shell/app-shell.js.map +1 -1
  285. package/dist/shell/option-switcher-menu.d.ts +5 -17
  286. package/dist/shell/option-switcher-menu.js +1 -3
  287. package/dist/shell/option-switcher-menu.js.map +1 -1
  288. package/dist/shell/role-switcher.d.ts +1 -4
  289. package/dist/shell/role-switcher.js +2 -3
  290. package/dist/shell/role-switcher.js.map +1 -1
  291. package/dist/shell/topbar-action-menu.d.ts +7 -19
  292. package/dist/shell/topbar-action-menu.js +1 -3
  293. package/dist/shell/topbar-action-menu.js.map +1 -1
  294. package/dist/shell/topbar-controls.d.ts +3 -12
  295. package/dist/shell/topbar-controls.js +6 -9
  296. package/dist/shell/topbar-controls.js.map +1 -1
  297. package/dist/testing/index.d.ts +1 -1
  298. package/dist/testing/index.js +2 -0
  299. package/dist/testing/index.js.map +1 -1
  300. package/dist/testing/viewport.d.ts +25 -2
  301. package/dist/testing/viewport.js +61 -10
  302. package/dist/testing/viewport.js.map +1 -1
  303. package/dist/theme/contrast-tokens.d.ts +42 -4
  304. package/dist/theme/contrast-tokens.js +56 -9
  305. package/dist/theme/contrast-tokens.js.map +1 -1
  306. package/dist/theme/contrast.d.ts +4 -2
  307. package/dist/theme/contrast.js +13 -0
  308. package/dist/theme/contrast.js.map +1 -1
  309. package/dist/theme/palette-derive.d.ts +15 -1
  310. package/dist/theme/palette-derive.js +37 -8
  311. package/dist/theme/palette-derive.js.map +1 -1
  312. package/dist/theme/palette-presets.d.ts +2 -8
  313. package/dist/theme/palette-presets.js +5 -284
  314. package/dist/theme/palette-presets.js.map +1 -1
  315. package/dist/theme/palette-store.js +6 -0
  316. package/dist/theme/palette-store.js.map +1 -1
  317. package/dist/theme/text-size.js +6 -0
  318. package/dist/theme/text-size.js.map +1 -1
  319. package/dist/theme/theme-store.js +6 -0
  320. package/dist/theme/theme-store.js.map +1 -1
  321. package/dist/wizard/types.d.ts +0 -5
  322. package/dist/wizard/types.js +1 -5
  323. package/dist/wizard/types.js.map +1 -1
  324. package/package.json +9 -1
  325. package/src/admin/role-select.tsx +4 -1
  326. package/src/billing/billing-banner.tsx +5 -2
  327. package/src/billing/plan-limit-notice.tsx +5 -2
  328. package/src/chart.ts +5 -3
  329. package/src/components/amount-input.tsx +4 -7
  330. package/src/components/autocomplete.tsx +5 -2
  331. package/src/components/button-look.ts +15 -5
  332. package/src/components/calculator.tsx +2 -7
  333. package/src/components/chart-zoom.tsx +4 -47
  334. package/src/components/checkbox-group.tsx +22 -4
  335. package/src/components/chip-input.tsx +0 -7
  336. package/src/components/column-mapper.tsx +6 -2
  337. package/src/components/combobox.tsx +6 -3
  338. package/src/components/copy-button.tsx +5 -1
  339. package/src/components/country-select.tsx +6 -1
  340. package/src/components/currency-option-list.tsx +8 -42
  341. package/src/components/currency-select.tsx +12 -3
  342. package/src/components/danger-confirm.tsx +3 -3
  343. package/src/components/data-table-filter-popover.tsx +2 -1
  344. package/src/components/data-table-labels.ts +2 -84
  345. package/src/components/data-table-shared.tsx +55 -2
  346. package/src/components/data-table.tsx +29 -17
  347. package/src/components/date-field.tsx +2 -2
  348. package/src/components/date-picker.tsx +3 -0
  349. package/src/components/date-range-picker.tsx +3 -0
  350. package/src/components/dialog-frame.tsx +3 -1
  351. package/src/components/dropdown.tsx +42 -18
  352. package/src/components/entity-combobox.tsx +6 -3
  353. package/src/components/field-base.tsx +0 -11
  354. package/src/components/field-parts.tsx +112 -38
  355. package/src/components/field-strip.tsx +12 -27
  356. package/src/components/field-sync.tsx +0 -7
  357. package/src/components/field.tsx +32 -19
  358. package/src/components/full-bleed-dialog.tsx +4 -3
  359. package/src/components/help-popover.tsx +9 -7
  360. package/src/components/hover-menu.tsx +12 -16
  361. package/src/components/icon-picker.tsx +8 -3
  362. package/src/components/inline-entity-combobox.tsx +6 -3
  363. package/src/components/input.tsx +4 -2
  364. package/src/components/modal.tsx +20 -4
  365. package/src/components/month-picker.tsx +10 -3
  366. package/src/components/multi-entity-combobox.tsx +5 -2
  367. package/src/components/multi-select.tsx +19 -31
  368. package/src/components/number-input.tsx +5 -10
  369. package/src/components/numeric-field.ts +10 -2
  370. package/src/components/one-time-code-input.tsx +5 -3
  371. package/src/components/phone-input.tsx +4 -0
  372. package/src/components/popover.tsx +8 -4
  373. package/src/components/select.tsx +6 -3
  374. package/src/components/series-chart.tsx +1 -1
  375. package/src/components/server-wake.tsx +0 -6
  376. package/src/components/settings-fields.tsx +4 -2
  377. package/src/components/slider.tsx +10 -3
  378. package/src/components/stat-tile.tsx +11 -20
  379. package/src/components/swatch-picker.tsx +9 -4
  380. package/src/components/swipeable-row.tsx +15 -37
  381. package/src/components/text-link.tsx +3 -1
  382. package/src/components/textarea.tsx +11 -4
  383. package/src/components/toggle-group.tsx +21 -5
  384. package/src/components/tooltip.tsx +37 -6
  385. package/src/components/translation-review.tsx +25 -7
  386. package/src/components/use-table-state.ts +91 -10
  387. package/src/components/user-avatar.tsx +2 -2
  388. package/src/components/wizard-stepper.tsx +2 -6
  389. package/src/currencies.ts +70 -0
  390. package/src/demo/demo-banner.tsx +7 -2
  391. package/src/eslint/index.ts +161 -0
  392. package/src/eslint/role-utilities.generated.ts +146 -0
  393. package/src/eslint/role-utilities.ts +99 -0
  394. package/src/feedback/feedback-menu.tsx +2 -2
  395. package/src/feedback/feedback-thread.tsx +3 -8
  396. package/src/hooks/use-dismiss.ts +39 -4
  397. package/src/i18n/defaults.ts +4 -0
  398. package/src/i18n/german.ts +113 -0
  399. package/src/i18n/kit-labels.tsx +66 -6
  400. package/src/i18n/locales/en.ts +6 -0
  401. package/src/i18n/locales/es.ts +106 -0
  402. package/src/i18n/locales/fr.ts +109 -2
  403. package/src/i18n/locales/hu.ts +104 -0
  404. package/src/i18n/locales/it.ts +108 -2
  405. package/src/i18n/locales/zh.ts +96 -0
  406. package/src/i18n/review.ts +13 -0
  407. package/src/i18n/shared-labels.ts +2 -0
  408. package/src/index.ts +53 -1
  409. package/src/lib/device-settings.ts +146 -0
  410. package/src/lib/plain-text.ts +22 -0
  411. package/src/lib/refs.ts +22 -0
  412. package/src/lib/safe-storage.ts +16 -0
  413. package/src/notifications/notification-parts.tsx +83 -0
  414. package/src/notifications/notification-types-setting.tsx +309 -0
  415. package/src/notifications/notification-types.ts +80 -0
  416. package/src/notifications/notifications-labels.ts +238 -0
  417. package/src/notifications/push-device-setting.tsx +224 -0
  418. package/src/notifications/push-devices-setting.tsx +306 -0
  419. package/src/notifications/quiet-hours-setting.tsx +185 -0
  420. package/src/notifications/time-zone-select.tsx +126 -0
  421. package/src/notifications/unsubscribe-panel.tsx +203 -0
  422. package/src/notifications/use-push-device.ts +392 -0
  423. package/src/rhf/fields.tsx +87 -39
  424. package/src/rhf/form-core.tsx +5 -4
  425. package/src/rhf/form.tsx +7 -43
  426. package/src/rhf/line-items.tsx +1 -1
  427. package/src/rhf/use-rhf-wizard-step.ts +4 -4
  428. package/src/rhf.ts +3 -2
  429. package/src/settings/device-reset-setting-labels.ts +20 -0
  430. package/src/settings/device-reset-setting.tsx +119 -0
  431. package/src/shell/app-shell.tsx +8 -0
  432. package/src/shell/option-switcher-menu.tsx +5 -22
  433. package/src/shell/role-switcher.tsx +2 -6
  434. package/src/shell/topbar-action-menu.tsx +7 -24
  435. package/src/shell/topbar-controls.tsx +6 -18
  436. package/src/testing/index.ts +3 -1
  437. package/src/testing/viewport.ts +125 -19
  438. package/src/theme/contrast-tokens.ts +106 -12
  439. package/src/theme/contrast.ts +30 -4
  440. package/src/theme/palette-derive.ts +68 -7
  441. package/src/theme/palette-presets.ts +16 -299
  442. package/src/theme/palette-store.ts +12 -0
  443. package/src/theme/text-size.ts +8 -0
  444. package/src/theme/theme-store.ts +10 -0
  445. package/src/wizard/types.ts +0 -8
  446. package/tokens.css +38 -4
@@ -0,0 +1,203 @@
1
+ import { useRef, useState } from "react";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+ import { CheckCircle2, Link2Off } from "lucide-react";
4
+
5
+ import type { LabelOverride } from "../i18n/kit-labels";
6
+ import { cn } from "../lib/cn";
7
+ import { errorBodyWithCode, httpStatusOf } from "../auth/auth-errors";
8
+ import { INTRO_CLASS, OUTCOME_CLASS, STATUS_HEADING_CLASS, useFocusWhen } from "../auth/status-parts";
9
+ import type { AuthHeadingLevel } from "../auth/status-parts";
10
+ import { Button } from "../components/button";
11
+ import { EmptyState } from "../components/empty-state";
12
+ import { useMounted } from "../account/account-parts";
13
+ import { FailureLine, useNotificationsLabels } from "./notification-parts";
14
+ import type { NotificationsLabels } from "./notifications-labels";
15
+
16
+ /** A type id, as the server's registry allows it (§2.1). */
17
+ const TYPE_ID = /^[a-z][a-z0-9_]{1,39}$/;
18
+ /** base64url, unpadded, as the token's `<data>` is written (§3.6). */
19
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
20
+
21
+ /**
22
+ * The notification type an unsubscribe token names, read from its `<data>` half —
23
+ * `v1.<base64url("<user_id>:<type id>")>.<mac>` (docs/notifications-harmonization.md
24
+ * §3.6) — or `null` for a token that does not parse. Only the claim is read: the MAC is
25
+ * the server's to check, so a forged token still names a type here and the server
26
+ * refuses it. For the page's words ("You get e-mails about: Overdue invoices").
27
+ */
28
+ export function unsubscribeTokenType(token: string): string | null {
29
+ const parts = token.split(".");
30
+ if (parts.length !== 3) return null;
31
+ const [version, data, mac] = parts;
32
+ if (version !== "v1" || !BASE64URL.test(data) || !BASE64URL.test(mac) || data.length % 4 === 1) return null;
33
+ let claim: string;
34
+ try {
35
+ claim = atob(data.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (data.length % 4)) % 4));
36
+ } catch {
37
+ return null;
38
+ }
39
+ const colon = claim.indexOf(":");
40
+ if (colon < 1) return null;
41
+ const type = claim.slice(colon + 1);
42
+ return TYPE_ID.test(type) ? type : null;
43
+ }
44
+
45
+ /** server-kit's `UnsubscribeTokenError`: 400 `{code: "unsubscribe_invalid"}`. A body
46
+ * thrown as it came, with no status, counts on its code. */
47
+ function defaultIsInvalid(error: unknown): boolean {
48
+ const coded = errorBodyWithCode(error, (code) => code === "unsubscribe_invalid") !== undefined;
49
+ const status = httpStatusOf(error);
50
+ return coded && (status === undefined || status === 400);
51
+ }
52
+
53
+ export interface UnsubscribePanelProps extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
54
+ /** The `token` from the page's query (`/unsubscribe?token=…`). */
55
+ token: string;
56
+ /** The app's name, for "Stop all notification e-mails from {app}". */
57
+ appName: string;
58
+ /** The type's name in the app's words, from {@link unsubscribeTokenType}. Without it
59
+ * the page says only that notification mail comes. */
60
+ typeLabel?: string;
61
+ /** `POST /notifications/unsubscribe/{token}`: that type's e-mail off. */
62
+ onStop: (token: string) => Promise<unknown>;
63
+ /** `POST /notifications/unsubscribe/{token}/all`: every e-mail switch off. */
64
+ onStopAll: (token: string) => Promise<unknown>;
65
+ /** Settings → Notifications, through the provider's link component. */
66
+ settingsHref: string;
67
+ /** Whether a rejection means the link is no good. Default: a 400 with
68
+ * `code: "unsubscribe_invalid"`. Anything else is "try again". */
69
+ isInvalid?: (error: unknown) => boolean;
70
+ /** The heading. Default `labels.unsubscribeTitle`; `null` draws none, for a page that
71
+ * puts it in `AuthLayout`'s `title`. */
72
+ title?: ReactNode;
73
+ /** The heading's level. Default `h2`, under the page's `h1`. */
74
+ headingAs?: AuthHeadingLevel;
75
+ labels?: LabelOverride<NotificationsLabels>;
76
+ }
77
+
78
+ type Phase = "ready" | "these" | "all" | "stopped-these" | "stopped-all" | "invalid" | "failed";
79
+
80
+ /**
81
+ * The body of an app's signed-out `/unsubscribe` page (docs/notifications-harmonization.md
82
+ * §3.6, §4.2) — the page a notification mail's footer links to. It works signed in or
83
+ * out (auth §7), and is not a card.
84
+ *
85
+ * **Nothing happens on a GET**: link scanners fetch every URL, so the page asks first —
86
+ * "Stop these e-mails", or "Stop all notification e-mails from {app}" — and only a press
87
+ * POSTs. A mail client's one-click POST goes to the server's route directly, without
88
+ * this page. Account mail is not a notification and keeps coming; the page says so, and
89
+ * then how to turn the mail back on (Settings → Notifications).
90
+ *
91
+ * A token that does not parse is "this link doesn't work" at once, with no request; the
92
+ * server's refusal (`unsubscribe_invalid`) says the same. Any other failure is "try
93
+ * again", the buttons still there. A valid token always answers 200 — also when the
94
+ * switch was already off or the account is gone — so the done state reveals nothing.
95
+ */
96
+ export function UnsubscribePanel({
97
+ token,
98
+ appName,
99
+ typeLabel,
100
+ onStop,
101
+ onStopAll,
102
+ settingsHref,
103
+ isInvalid = defaultIsInvalid,
104
+ title,
105
+ headingAs: Heading = "h2",
106
+ labels: labelsProp,
107
+ className,
108
+ ...rest
109
+ }: UnsubscribePanelProps) {
110
+ const labels = useNotificationsLabels(labelsProp);
111
+ const parses = unsubscribeTokenType(token) !== null;
112
+ const [phase, setPhase] = useState<Phase>("ready");
113
+ const mounted = useMounted();
114
+ const outcomeRef = useRef<HTMLDivElement>(null);
115
+ const shown: Phase = parses ? phase : "invalid";
116
+ const done = shown === "stopped-these" || shown === "stopped-all";
117
+ useFocusWhen(done, outcomeRef);
118
+
119
+ const stop = (which: "these" | "all") => {
120
+ if (shown !== "ready" && shown !== "failed") return;
121
+ setPhase(which);
122
+ const call = which === "these" ? onStop : onStopAll;
123
+ call(token).then(
124
+ () => {
125
+ if (mounted.current) setPhase(which === "these" ? "stopped-these" : "stopped-all");
126
+ },
127
+ (error: unknown) => {
128
+ if (mounted.current) setPhase(isInvalid(error) ? "invalid" : "failed");
129
+ },
130
+ );
131
+ };
132
+
133
+ const settingsLink = (
134
+ <Button href={settingsHref} variant="secondary">
135
+ {labels.openSettings}
136
+ </Button>
137
+ );
138
+
139
+ let body: ReactNode;
140
+ if (shown === "invalid") {
141
+ body = (
142
+ <EmptyState
143
+ tone="danger"
144
+ icon={<Link2Off />}
145
+ title={labels.invalidLink}
146
+ action={settingsLink}
147
+ className={OUTCOME_CLASS}
148
+ />
149
+ );
150
+ } else if (done) {
151
+ body = (
152
+ <div ref={outcomeRef} tabIndex={-1} className="outline-none">
153
+ <EmptyState
154
+ tone="success"
155
+ icon={<CheckCircle2 />}
156
+ title={shown === "stopped-these" ? labels.stoppedThese : labels.stoppedAll(appName)}
157
+ hint={
158
+ <>
159
+ {labels.accountMail} {labels.turnBackOn}
160
+ </>
161
+ }
162
+ action={settingsLink}
163
+ className={OUTCOME_CLASS}
164
+ />
165
+ </div>
166
+ );
167
+ } else {
168
+ const working = shown === "these" || shown === "all";
169
+ body = (
170
+ <>
171
+ <p className={INTRO_CLASS}>{typeLabel ? labels.unsubscribeAbout(typeLabel) : labels.unsubscribeGeneric}</p>
172
+ <p className={INTRO_CLASS}>{labels.accountMail}</p>
173
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
174
+ <Button
175
+ type="button"
176
+ pending={shown === "these"}
177
+ disabled={working && shown !== "these"}
178
+ onClick={() => stop("these")}
179
+ >
180
+ {labels.stopThese}
181
+ </Button>
182
+ <Button
183
+ type="button"
184
+ variant="secondary"
185
+ pending={shown === "all"}
186
+ disabled={working && shown !== "all"}
187
+ onClick={() => stop("all")}
188
+ >
189
+ {labels.stopAll(appName)}
190
+ </Button>
191
+ </div>
192
+ {shown === "failed" && <FailureLine>{labels.failed}</FailureLine>}
193
+ </>
194
+ );
195
+ }
196
+
197
+ return (
198
+ <div {...rest} data-state={shown} className={cn("space-y-3", className)}>
199
+ {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.unsubscribeTitle}</Heading>}
200
+ {body}
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,392 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import { useMediaQuery } from "../hooks/use-media-query";
4
+ import { httpStatusOf } from "../auth/auth-errors";
5
+
6
+ /**
7
+ * This browser's Web Push subscription for the signed-in account
8
+ * (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's
9
+ * `shared/push/use-push.ts`, lifted. The service worker with its `push` and
10
+ * `notificationclick` handlers stays the app's; this hook owns the browser's side:
11
+ * permission, subscribe with the server's VAPID key, register the subscription, and
12
+ * unsubscribe. The kit makes no request of its own: the app hands it one function per
13
+ * endpoint.
14
+ */
15
+
16
+ /** The browser's notification permission, or `"unsupported"` where it has no push. */
17
+ export type PushPermission = NotificationPermission | "unsupported";
18
+
19
+ /** Why turning push on or off could not finish — a {@link PushDeviceError}'s `code`. */
20
+ export type PushDeviceErrorCode = "no_service_worker" | "push_timeout";
21
+
22
+ /**
23
+ * The two failures the card has words of its own for: no service worker registered (or
24
+ * one not ready in 10 s) — "reload and try again" — and a browser push service that did
25
+ * not answer in 15 s — "check your connection". Anything else is the app's request
26
+ * failing, rejected as it came.
27
+ */
28
+ export class PushDeviceError extends Error {
29
+ readonly code: PushDeviceErrorCode;
30
+ constructor(code: PushDeviceErrorCode) {
31
+ super(code);
32
+ this.name = "PushDeviceError";
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ /** What `register` sends: `POST /notifications/devices` (§3.5). */
38
+ export interface PushSubscriptionInput {
39
+ endpoint: string;
40
+ keys: { p256dh: string; auth: string };
41
+ user_agent: string | null;
42
+ }
43
+
44
+ export interface UsePushDeviceOptions {
45
+ /** `GET /notifications/push-key` → its `key`; `""` when push is off on the server. */
46
+ pushKey: () => Promise<string>;
47
+ /** `POST /notifications/devices`. A 409 (another account's endpoint) is recovered
48
+ * once: the hook unsubscribes and registers a fresh endpoint. */
49
+ register: (subscription: PushSubscriptionInput) => Promise<unknown>;
50
+ /** `POST /notifications/devices/unregister {endpoint}` — the endpoint never goes in a
51
+ * URL. */
52
+ unregister: (endpoint: string) => Promise<unknown>;
53
+ /** `GET /notifications/devices`: the account's own list, which says whether THIS
54
+ * account holds this browser's subscription. */
55
+ devices: () => Promise<readonly { endpoint: string }[]>;
56
+ /** `POST /notifications/test` → how many devices the push service accepted it for. */
57
+ test: () => Promise<{ sent: number }>;
58
+ /** Whether `register` refused another account's endpoint. Default: HTTP 409. */
59
+ isEndpointTaken?: (error: unknown) => boolean;
60
+ /** After an enable or a disable: the app refreshes its device queries. */
61
+ onChange?: () => void;
62
+ }
63
+
64
+ /** What {@link usePushDevice} answers. */
65
+ export interface PushDevice {
66
+ /** The browser exposes service workers, `PushManager` and `Notification`. */
67
+ supported: boolean;
68
+ /** The server has VAPID keys; `undefined` until `pushKey` answered (or when it failed). */
69
+ configured: boolean | undefined;
70
+ /** Running as an installed app: `display-mode: standalone`, or iOS's
71
+ * `navigator.standalone`. */
72
+ installed: boolean;
73
+ /** iPhone or iPad — where push reaches only the installed app. */
74
+ ios: boolean;
75
+ permission: PushPermission;
76
+ /** THIS account holds this browser's subscription. An unreachable device list falls
77
+ * back to the browser's own answer. */
78
+ subscribed: boolean;
79
+ /** This browser holds a subscription that belongs to another account (two accounts in
80
+ * one browser profile): `subscribed` is false, and turning on mints a fresh one. */
81
+ foreignSubscription: boolean;
82
+ /** This browser's subscription endpoint, or `null` — marks "This device" in a list. */
83
+ currentEndpoint: string | null;
84
+ /** An enable or a disable is running. */
85
+ busy: boolean;
86
+ /** Ask for permission (the browser's one prompt), subscribe and register. `false` when
87
+ * the permission was not granted or push is off on the server; rejects with a
88
+ * {@link PushDeviceError} or the app's request's error. */
89
+ enable: () => Promise<boolean>;
90
+ /** Tell the server first, then the browser, so a dropped device stops at once. */
91
+ disable: () => Promise<void>;
92
+ sendTest: () => Promise<{ sent: number }>;
93
+ }
94
+
95
+ /**
96
+ * The window event every mounted {@link usePushDevice} re-reads its state on. Fired by
97
+ * every enable and disable, and by `PushDevicesSetting` when it removes this device — so
98
+ * an app's own card on the same hook (keksdose's check-up) stays in step. The hook that
99
+ * fires it does not re-read: it knows what it changed. Namespaced for the kit;
100
+ * keksdose's was `"keksdose:push-subscription-changed"`.
101
+ */
102
+ export const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
103
+
104
+ /** Tell every {@link usePushDevice} that this browser's subscription changed. */
105
+ export function notifyPushSubscriptionChanged(): void {
106
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(PUSH_SUBSCRIPTION_CHANGED));
107
+ }
108
+
109
+ /** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED
110
+ * app (Add to Home Screen). */
111
+ export function pushSupported(): boolean {
112
+ return (
113
+ typeof window !== "undefined" &&
114
+ typeof navigator !== "undefined" &&
115
+ "serviceWorker" in navigator &&
116
+ "PushManager" in window &&
117
+ "Notification" in window
118
+ );
119
+ }
120
+
121
+ /** iPhone or iPad. iPadOS 13+ reports itself as a Mac; the touch points tell it apart. */
122
+ function isIos(): boolean {
123
+ if (typeof navigator === "undefined") return false;
124
+ const ua = navigator.userAgent;
125
+ return /iphone|ipad|ipod/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
126
+ }
127
+
128
+ const READY_TIMEOUT_MS = 10_000;
129
+ const PUSH_TIMEOUT_MS = 15_000;
130
+
131
+ /** `promise`, or a {@link PushDeviceError} after `ms`. The timer is cleared either way. */
132
+ function within<T>(promise: Promise<T>, ms: number, code: PushDeviceErrorCode): Promise<T> {
133
+ let timer: ReturnType<typeof setTimeout> | undefined;
134
+ return Promise.race([
135
+ promise,
136
+ new Promise<never>((_, reject) => {
137
+ timer = setTimeout(() => reject(new PushDeviceError(code)), ms);
138
+ }),
139
+ ]).finally(() => clearTimeout(timer));
140
+ }
141
+
142
+ /**
143
+ * The active registration. `navigator.serviceWorker.ready` never settles while the origin
144
+ * has no registration, so a missing one fails at once, and one stuck installing fails
145
+ * after 10 s (keksdose: enable() hung with the button latched before its worker was
146
+ * served).
147
+ */
148
+ async function activeRegistration(): Promise<ServiceWorkerRegistration> {
149
+ const existing = await navigator.serviceWorker.getRegistration();
150
+ if (!existing) throw new PushDeviceError("no_service_worker");
151
+ return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, "no_service_worker");
152
+ }
153
+
154
+ /** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
155
+ function keyBytes(key: string): Uint8Array<ArrayBuffer> {
156
+ const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
157
+ const raw = atob(base64);
158
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
159
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
160
+ return bytes;
161
+ }
162
+
163
+ /** A subscription's register body, or `null` when the browser handed back an incomplete
164
+ * one. */
165
+ function inputOf(subscription: PushSubscription): PushSubscriptionInput | null {
166
+ const json = subscription.toJSON();
167
+ const p256dh = json.keys?.p256dh;
168
+ const auth = json.keys?.auth;
169
+ if (!json.endpoint || !p256dh || !auth) return null;
170
+ return { endpoint: json.endpoint, keys: { p256dh, auth }, user_agent: navigator.userAgent || null };
171
+ }
172
+
173
+ const defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;
174
+
175
+ /**
176
+ * This browser's push for the signed-in account. Hand it one function per endpoint
177
+ * (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.
178
+ *
179
+ * **"Subscribed" means this account**, not "this browser holds a subscription": a
180
+ * browser profile's subscription has no account in it, so a second account in the same
181
+ * profile would read the first's as its own (keksdose). The endpoint is checked against
182
+ * the account's own `devices()`; when that list cannot be read (offline), the browser's
183
+ * answer stands — a wrong "on" costs what it did before the check, a wrong "off" invites
184
+ * re-subscribing a device that is fine.
185
+ *
186
+ * **A 409 from `register`** — the endpoint belongs to another account, and the server
187
+ * will not re-home what it cannot prove this browser holds — unsubscribes and registers
188
+ * a fresh endpoint, once.
189
+ *
190
+ * **Timeouts:** no registration fails at once and a worker not ready in 10 s fails, both
191
+ * `PushDeviceError("no_service_worker")`; the browser's subscribe and unsubscribe are cut
192
+ * at 15 s, `PushDeviceError("push_timeout")` — so `busy` can never latch.
193
+ *
194
+ * Every mounted hook re-reads its state on {@link PUSH_SUBSCRIPTION_CHANGED}.
195
+ */
196
+ export function usePushDevice(options: UsePushDeviceOptions): PushDevice {
197
+ // The app's functions are usually new on every render; the effects read the latest.
198
+ const latest = useRef(options);
199
+ useEffect(() => {
200
+ latest.current = options;
201
+ });
202
+
203
+ const supported = pushSupported();
204
+ const ios = isIos();
205
+ const standalone = useMediaQuery("(display-mode: standalone)", false);
206
+ const installed =
207
+ standalone ||
208
+ (typeof navigator !== "undefined" && (navigator as Navigator & { standalone?: boolean }).standalone === true);
209
+
210
+ const [permission, setPermission] = useState<PushPermission>(() =>
211
+ supported ? Notification.permission : "unsupported",
212
+ );
213
+ const [configured, setConfigured] = useState<boolean | undefined>(undefined);
214
+ const [subscribed, setSubscribed] = useState(false);
215
+ const [foreignSubscription, setForeignSubscription] = useState(false);
216
+ const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);
217
+ const [busy, setBusy] = useState(false);
218
+ const mounted = useRef(true);
219
+ // Set while this hook fires the event itself: it already knows what changed, and a
220
+ // re-read could only be worse — an app whose `devices()` answers from a cache would
221
+ // hand back the list from before the change.
222
+ const announcing = useRef(false);
223
+ const announce = useCallback(() => {
224
+ announcing.current = true;
225
+ try {
226
+ notifyPushSubscriptionChanged();
227
+ } finally {
228
+ announcing.current = false;
229
+ }
230
+ latest.current.onChange?.();
231
+ }, []);
232
+ useEffect(() => {
233
+ mounted.current = true;
234
+ return () => {
235
+ mounted.current = false;
236
+ };
237
+ }, []);
238
+
239
+ // Whether the server has keys ("" → push is off there). Once per mount.
240
+ useEffect(() => {
241
+ if (!supported) return;
242
+ let cancelled = false;
243
+ latest.current.pushKey().then(
244
+ (key) => {
245
+ if (!cancelled) setConfigured(key !== "");
246
+ },
247
+ () => {
248
+ /* unknown: the card shows the ordinary states */
249
+ },
250
+ );
251
+ return () => {
252
+ cancelled = true;
253
+ };
254
+ }, [supported]);
255
+
256
+ // What this browser holds, and whether it is this account's — at mount, and whenever
257
+ // a subscription changes anywhere in the app.
258
+ useEffect(() => {
259
+ if (!supported) return;
260
+ let cancelled = false;
261
+ const sync = async () => {
262
+ try {
263
+ const registration = await activeRegistration();
264
+ const existing = await registration.pushManager.getSubscription();
265
+ if (cancelled) return;
266
+ // Read again: the person may have changed it in the browser's settings.
267
+ setPermission(Notification.permission);
268
+ if (!existing) {
269
+ setSubscribed(false);
270
+ setForeignSubscription(false);
271
+ setCurrentEndpoint(null);
272
+ return;
273
+ }
274
+ let mine: boolean;
275
+ try {
276
+ const devices = await latest.current.devices();
277
+ mine = devices.some((device) => device.endpoint === existing.endpoint);
278
+ } catch {
279
+ mine = true;
280
+ }
281
+ if (cancelled) return;
282
+ setCurrentEndpoint(existing.endpoint);
283
+ setSubscribed(mine);
284
+ setForeignSubscription(!mine);
285
+ } catch {
286
+ /* no active service worker yet: not subscribed */
287
+ }
288
+ };
289
+ const heard = () => {
290
+ if (!announcing.current) void sync();
291
+ };
292
+ void sync();
293
+ window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
294
+ return () => {
295
+ cancelled = true;
296
+ window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
297
+ };
298
+ }, [supported]);
299
+
300
+ const enable = useCallback(async (): Promise<boolean> => {
301
+ if (!supported) return false;
302
+ const { pushKey, register, isEndpointTaken = defaultEndpointTaken } = latest.current;
303
+ setBusy(true);
304
+ try {
305
+ const granted = await Notification.requestPermission();
306
+ if (mounted.current) setPermission(granted);
307
+ if (granted !== "granted") return false;
308
+
309
+ const key = await pushKey();
310
+ if (!key) {
311
+ if (mounted.current) setConfigured(false);
312
+ return false;
313
+ }
314
+ if (mounted.current) setConfigured(true);
315
+
316
+ const registration = await activeRegistration();
317
+ // The one place the browser is asked for a subscription, so the timeout and the
318
+ // `{userVisibleOnly, applicationServerKey}` literal are on both paths.
319
+ const subscribe = (reuse: boolean) =>
320
+ within(
321
+ (async () =>
322
+ (reuse ? await registration.pushManager.getSubscription() : null) ??
323
+ (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) })))(),
324
+ PUSH_TIMEOUT_MS,
325
+ "push_timeout",
326
+ );
327
+
328
+ let subscription = await subscribe(true);
329
+ let input = inputOf(subscription);
330
+ if (!input) return false;
331
+ try {
332
+ await register(input);
333
+ } catch (error) {
334
+ if (!isEndpointTaken(error)) throw error;
335
+ await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, "push_timeout");
336
+ subscription = await subscribe(false);
337
+ input = inputOf(subscription);
338
+ if (!input) return false;
339
+ await register(input);
340
+ }
341
+ if (mounted.current) {
342
+ setSubscribed(true);
343
+ setForeignSubscription(false);
344
+ setCurrentEndpoint(input.endpoint);
345
+ }
346
+ announce();
347
+ return true;
348
+ } finally {
349
+ if (mounted.current) setBusy(false);
350
+ }
351
+ }, [supported, announce]);
352
+
353
+ const disable = useCallback(async (): Promise<void> => {
354
+ if (!supported) return;
355
+ setBusy(true);
356
+ try {
357
+ const registration = await activeRegistration();
358
+ const subscription = await registration.pushManager.getSubscription();
359
+ if (subscription) {
360
+ // The server first: a dropped device stops receiving even if the browser's
361
+ // unsubscribe below fails.
362
+ await latest.current.unregister(subscription.endpoint);
363
+ await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, "push_timeout");
364
+ }
365
+ if (mounted.current) {
366
+ setSubscribed(false);
367
+ setForeignSubscription(false);
368
+ setCurrentEndpoint(null);
369
+ }
370
+ announce();
371
+ } finally {
372
+ if (mounted.current) setBusy(false);
373
+ }
374
+ }, [supported, announce]);
375
+
376
+ const sendTest = useCallback(() => latest.current.test(), []);
377
+
378
+ return {
379
+ supported,
380
+ configured,
381
+ installed,
382
+ ios,
383
+ permission,
384
+ subscribed,
385
+ foreignSubscription,
386
+ currentEndpoint,
387
+ busy,
388
+ enable,
389
+ disable,
390
+ sendTest,
391
+ };
392
+ }