@eifi1/ui-kit 0.45.0 → 0.46.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 (228) hide show
  1. package/README.md +23 -12
  2. package/dist/admin/admin-area-labels.d.ts +9 -0
  3. package/dist/admin/admin-area-labels.js +5 -1
  4. package/dist/admin/admin-area-labels.js.map +1 -1
  5. package/dist/admin/admin-overview.d.ts +16 -1
  6. package/dist/admin/admin-overview.js +12 -1
  7. package/dist/admin/admin-overview.js.map +1 -1
  8. package/dist/admin/mail-test-card.d.ts +3 -2
  9. package/dist/admin/mail-test-card.js +3 -1
  10. package/dist/admin/mail-test-card.js.map +1 -1
  11. package/dist/components/appearance-labels.d.ts +10 -0
  12. package/dist/components/appearance-labels.js +6 -1
  13. package/dist/components/appearance-labels.js.map +1 -1
  14. package/dist/components/data-table-column-settings.js +2 -2
  15. package/dist/components/data-table-column-settings.js.map +1 -1
  16. package/dist/components/data-table-head.js +2 -2
  17. package/dist/components/data-table-head.js.map +1 -1
  18. package/dist/components/data-table-row-actions.d.ts +18 -2
  19. package/dist/components/data-table-row-actions.js +86 -2
  20. package/dist/components/data-table-row-actions.js.map +1 -1
  21. package/dist/components/data-table-shared.js +9 -2
  22. package/dist/components/data-table-shared.js.map +1 -1
  23. package/dist/components/data-table.d.ts +43 -2
  24. package/dist/components/data-table.js +9 -4
  25. package/dist/components/data-table.js.map +1 -1
  26. package/dist/components/date-format-setting.d.ts +29 -0
  27. package/dist/components/date-format-setting.js +36 -0
  28. package/dist/components/date-format-setting.js.map +1 -0
  29. package/dist/components/swipeable-row.d.ts +8 -0
  30. package/dist/components/swipeable-row.js.map +1 -1
  31. package/dist/components/tabs.d.ts +3 -2
  32. package/dist/components/tabs.js +1 -1
  33. package/dist/components/tabs.js.map +1 -1
  34. package/dist/components/translation-review-editor.js +1 -0
  35. package/dist/components/translation-review-editor.js.map +1 -1
  36. package/dist/components/translation-review-labels.d.ts +5 -0
  37. package/dist/components/translation-review-labels.js +2 -0
  38. package/dist/components/translation-review-labels.js.map +1 -1
  39. package/dist/components/translation-review.d.ts +18 -8
  40. package/dist/components/translation-review.js +64 -7
  41. package/dist/components/translation-review.js.map +1 -1
  42. package/dist/components/write-lock-labels.js +2 -1
  43. package/dist/components/write-lock-labels.js.map +1 -1
  44. package/dist/components/write-lock.d.ts +33 -3
  45. package/dist/components/write-lock.js +9 -0
  46. package/dist/components/write-lock.js.map +1 -1
  47. package/dist/data-table.d.ts +1 -1
  48. package/dist/data-table.js.map +1 -1
  49. package/dist/eslint/index.js +1 -1
  50. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  51. package/dist/feedback/feedback-row-detail.js +17 -26
  52. package/dist/feedback/feedback-row-detail.js.map +1 -1
  53. package/dist/feedback/feedback-swipe.d.ts +18 -0
  54. package/dist/feedback/feedback-swipe.js +10 -1
  55. package/dist/feedback/feedback-swipe.js.map +1 -1
  56. package/dist/i18n/german.js +41 -5
  57. package/dist/i18n/german.js.map +1 -1
  58. package/dist/i18n/kit-labels.d.ts +12 -1
  59. package/dist/i18n/kit-labels.js +8 -2
  60. package/dist/i18n/kit-labels.js.map +1 -1
  61. package/dist/i18n/locales/en.js +40 -6
  62. package/dist/i18n/locales/en.js.map +2 -2
  63. package/dist/i18n/locales/es.js +41 -5
  64. package/dist/i18n/locales/es.js.map +1 -1
  65. package/dist/i18n/locales/fr.js +41 -5
  66. package/dist/i18n/locales/fr.js.map +1 -1
  67. package/dist/i18n/locales/hu.js +41 -5
  68. package/dist/i18n/locales/hu.js.map +1 -1
  69. package/dist/i18n/locales/it.js +41 -5
  70. package/dist/i18n/locales/it.js.map +1 -1
  71. package/dist/i18n/locales/zh.js +41 -5
  72. package/dist/i18n/locales/zh.js.map +1 -1
  73. package/dist/i18n/review.js +3 -1
  74. package/dist/i18n/review.js.map +1 -1
  75. package/dist/index.d.ts +15 -2
  76. package/dist/index.js +35 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/lib/date-format.d.ts +81 -0
  79. package/dist/lib/date-format.js +56 -0
  80. package/dist/lib/date-format.js.map +1 -0
  81. package/dist/lib/format.d.ts +3 -0
  82. package/dist/lib/format.js +5 -4
  83. package/dist/lib/format.js.map +1 -1
  84. package/dist/notifications/device-zone-report.d.ts +27 -0
  85. package/dist/notifications/device-zone-report.js +24 -0
  86. package/dist/notifications/device-zone-report.js.map +1 -0
  87. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  88. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  89. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  90. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  91. package/dist/notifications/feedback-notification-types.js +2 -1
  92. package/dist/notifications/feedback-notification-types.js.map +1 -1
  93. package/dist/notifications/notification-bell.d.ts +33 -0
  94. package/dist/notifications/notification-bell.js +74 -0
  95. package/dist/notifications/notification-bell.js.map +1 -0
  96. package/dist/notifications/notification-inbox.d.ts +77 -0
  97. package/dist/notifications/notification-inbox.js +206 -0
  98. package/dist/notifications/notification-inbox.js.map +1 -0
  99. package/dist/notifications/notification-types-setting.d.ts +6 -4
  100. package/dist/notifications/notification-types-setting.js +103 -45
  101. package/dist/notifications/notification-types-setting.js.map +1 -1
  102. package/dist/notifications/notification-types.d.ts +19 -7
  103. package/dist/notifications/notifications-labels.d.ts +41 -7
  104. package/dist/notifications/notifications-labels.js +18 -1
  105. package/dist/notifications/notifications-labels.js.map +1 -1
  106. package/dist/notifications/push-devices-setting.d.ts +20 -3
  107. package/dist/notifications/push-devices-setting.js +93 -33
  108. package/dist/notifications/push-devices-setting.js.map +1 -1
  109. package/dist/notifications/push-worker.d.ts +108 -0
  110. package/dist/notifications/push-worker.js +62 -0
  111. package/dist/notifications/push-worker.js.map +1 -0
  112. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  113. package/dist/notifications/quiet-hours-setting.js +9 -41
  114. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  115. package/dist/notifications/time-zone-select.d.ts +5 -2
  116. package/dist/notifications/time-zone-select.js.map +1 -1
  117. package/dist/notifications/use-push-device.d.ts +4 -17
  118. package/dist/notifications/use-push-device.js +7 -15
  119. package/dist/notifications/use-push-device.js.map +1 -1
  120. package/dist/offline/local-first.d.ts +33 -0
  121. package/dist/offline/local-first.js +39 -0
  122. package/dist/offline/local-first.js.map +1 -0
  123. package/dist/offline/query-persistence.d.ts +26 -1
  124. package/dist/offline/query-persistence.js +27 -23
  125. package/dist/offline/query-persistence.js.map +1 -1
  126. package/dist/offline/service-worker-updates.d.ts +46 -0
  127. package/dist/offline/service-worker-updates.js +39 -0
  128. package/dist/offline/service-worker-updates.js.map +1 -0
  129. package/dist/offline.d.ts +5 -2
  130. package/dist/offline.js +11 -2
  131. package/dist/offline.js.map +1 -1
  132. package/dist/push-worker.d.ts +2 -0
  133. package/dist/push-worker.js +14 -0
  134. package/dist/push-worker.js.map +1 -0
  135. package/dist/shell/account-menu-text-size.d.ts +33 -0
  136. package/dist/shell/account-menu-text-size.js +51 -0
  137. package/dist/shell/account-menu-text-size.js.map +1 -0
  138. package/dist/shell/top-bar-actions.d.ts +6 -2
  139. package/dist/shell/top-bar-actions.js +1 -0
  140. package/dist/shell/top-bar-actions.js.map +1 -1
  141. package/dist/shell.d.ts +2 -0
  142. package/dist/shell.js +2 -0
  143. package/dist/shell.js.map +1 -1
  144. package/dist/sync/db.d.ts +76 -0
  145. package/dist/sync/db.js +24 -0
  146. package/dist/sync/db.js.map +1 -0
  147. package/dist/sync/engine.d.ts +124 -0
  148. package/dist/sync/engine.js +474 -0
  149. package/dist/sync/engine.js.map +1 -0
  150. package/dist/sync/hooks.d.ts +30 -0
  151. package/dist/sync/hooks.js +67 -0
  152. package/dist/sync/hooks.js.map +1 -0
  153. package/dist/sync/testing.d.ts +28 -0
  154. package/dist/sync/testing.js +41 -0
  155. package/dist/sync/testing.js.map +1 -0
  156. package/dist/sync/wire.d.ts +50 -0
  157. package/dist/sync/wire.js +2 -0
  158. package/dist/sync/wire.js.map +1 -0
  159. package/dist/sync.d.ts +7 -0
  160. package/dist/sync.js +22 -0
  161. package/dist/sync.js.map +1 -0
  162. package/dist/tour/tour-labels.js +1 -0
  163. package/dist/tour/tour-labels.js.map +1 -1
  164. package/dist/tour/tour.d.ts +30 -0
  165. package/dist/tour/tour.js +50 -6
  166. package/dist/tour/tour.js.map +1 -1
  167. package/package.json +19 -1
  168. package/src/admin/admin-area-labels.ts +13 -0
  169. package/src/admin/admin-overview.tsx +37 -2
  170. package/src/admin/mail-test-card.tsx +7 -3
  171. package/src/components/appearance-labels.ts +16 -0
  172. package/src/components/data-table-column-settings.tsx +2 -2
  173. package/src/components/data-table-head.tsx +2 -2
  174. package/src/components/data-table-row-actions.tsx +118 -2
  175. package/src/components/data-table-shared.tsx +21 -2
  176. package/src/components/data-table.tsx +51 -6
  177. package/src/components/date-format-setting.tsx +64 -0
  178. package/src/components/swipeable-row.tsx +8 -0
  179. package/src/components/tabs.tsx +4 -3
  180. package/src/components/translation-review-editor.tsx +2 -0
  181. package/src/components/translation-review-labels.ts +7 -0
  182. package/src/components/translation-review.tsx +102 -14
  183. package/src/components/write-lock-labels.ts +1 -0
  184. package/src/components/write-lock.tsx +36 -3
  185. package/src/data-table.ts +1 -0
  186. package/src/feedback/feedback-row-detail.tsx +18 -23
  187. package/src/feedback/feedback-swipe.tsx +28 -1
  188. package/src/i18n/german.ts +44 -4
  189. package/src/i18n/kit-labels.tsx +18 -1
  190. package/src/i18n/locales/en.ts +1 -0
  191. package/src/i18n/locales/es.ts +44 -4
  192. package/src/i18n/locales/fr.ts +44 -4
  193. package/src/i18n/locales/hu.ts +44 -4
  194. package/src/i18n/locales/it.ts +44 -4
  195. package/src/i18n/locales/zh.ts +44 -2
  196. package/src/i18n/review.ts +3 -1
  197. package/src/index.ts +42 -0
  198. package/src/lib/date-format.ts +151 -0
  199. package/src/lib/format.ts +10 -4
  200. package/src/notifications/device-zone-report.ts +54 -0
  201. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  202. package/src/notifications/feedback-notification-types.ts +12 -5
  203. package/src/notifications/notification-bell.tsx +116 -0
  204. package/src/notifications/notification-inbox.tsx +304 -0
  205. package/src/notifications/notification-types-setting.tsx +151 -55
  206. package/src/notifications/notification-types.ts +20 -8
  207. package/src/notifications/notifications-labels.ts +69 -11
  208. package/src/notifications/push-devices-setting.tsx +123 -34
  209. package/src/notifications/push-worker.ts +160 -0
  210. package/src/notifications/quiet-hours-setting.tsx +41 -58
  211. package/src/notifications/time-zone-select.tsx +5 -2
  212. package/src/notifications/use-push-device.ts +20 -33
  213. package/src/offline/local-first.ts +76 -0
  214. package/src/offline/query-persistence.ts +57 -24
  215. package/src/offline/service-worker-updates.ts +81 -0
  216. package/src/offline.ts +7 -1
  217. package/src/push-worker.ts +25 -0
  218. package/src/shell/account-menu-text-size.tsx +77 -0
  219. package/src/shell/top-bar-actions.tsx +6 -1
  220. package/src/shell.ts +3 -0
  221. package/src/sync/db.ts +103 -0
  222. package/src/sync/engine.ts +699 -0
  223. package/src/sync/hooks.ts +93 -0
  224. package/src/sync/testing.ts +73 -0
  225. package/src/sync/wire.ts +61 -0
  226. package/src/sync.ts +29 -0
  227. package/src/tour/tour-labels.ts +1 -0
  228. package/src/tour/tour.tsx +100 -7
@@ -30,8 +30,13 @@ export interface NotificationsLabels {
30
30
  typesTitle: string;
31
31
  /** The line under it. */
32
32
  typesDescription: string;
33
- /** The e-mail switch under a type that offers mail. */
33
+ /** @deprecated 0.46 — the card's channels are columns now (`channelEmail`); removed in 0.47. Unused,
34
+ * kept so an app's override still type-checks. */
34
35
  byEmail: string;
36
+ /** 0.46 (§10.2): the channel columns' headings, and each switch's name. */
37
+ channelApp: string;
38
+ channelPush: string;
39
+ channelEmail: string;
35
40
  /** Under every e-mail switch while the account's address is unverified (decision 16):
36
41
  * no notification mail goes to an unverified address. */
37
42
  emailUnverified: string;
@@ -47,8 +52,12 @@ export interface NotificationsLabels {
47
52
  /* ── Quiet hours ────────────────────────────────────────────────────── */
48
53
  quietTitle: string;
49
54
  /** The line under the title; `inbox`: the app keeps an inbox, which a held push still
50
- * reaches. */
51
- quietDescription: (inbox: boolean) => string;
55
+ * reaches; `held` (0.46, §10.4): the app queues a held push and sends it when the
56
+ * window ends, instead of dropping it. */
57
+ quietDescription: (inbox: boolean, held?: boolean) => string;
58
+ /** 0.46 (§10.3, automatic only): the card names the account's zone, read-only, given
59
+ * the zone as `zoneName` writes it ("Europe / Zurich"); it follows the device last used. */
60
+ quietZone: (zone: string) => string;
52
61
  /** The start time's label. */
53
62
  quietFrom: string;
54
63
  /** The end time's label. */
@@ -57,12 +66,13 @@ export interface NotificationsLabels {
57
66
  quietIncomplete: string;
58
67
  /** The zone picker's label. */
59
68
  timeZone: string;
60
- /** Given the device's zone, as the picker names it, when it differs from the one the
61
- * server applies. */
69
+ /** @deprecated 0.46 — the zone is automatic (§10.3), the card offers no device zone; removed in 0.47.
70
+ * Unused, kept so an app's override still type-checks. */
62
71
  deviceZone: (zone: string) => string;
63
- /** The button that takes the device's zone. */
72
+ /** @deprecated 0.46 — the zone is automatic (§10.3), nothing to take; removed in 0.47. Unused, kept
73
+ * so an app's override still type-checks. */
64
74
  useDeviceZone: string;
65
- /** The zone picker's region headings. */
75
+ /** The zone's region headings, in the picker and in a zone's name ("Europe / Zurich"). */
66
76
  regions: NotificationRegionLabels;
67
77
 
68
78
  /* ── This device ────────────────────────────────────────────────────── */
@@ -123,6 +133,32 @@ export interface NotificationsLabels {
123
133
  removeItem: (device: string) => string;
124
134
  /** The question the row asks before it removes. */
125
135
  removePrompt: string;
136
+ /** 0.46 (§10.2): the disclosure on a device's row listing the types with push on. */
137
+ deviceTypes: string;
138
+ /** The line inside it. */
139
+ deviceTypesDescription: string;
140
+ /** No type has push on for the account. */
141
+ deviceTypesEmpty: string;
142
+
143
+ /* ── The inbox and the bell (0.46, §10.1) ───────────────────────────── */
144
+ /** The inbox's heading (keksdose's "Inbox"). */
145
+ inboxTitle: string;
146
+ inboxLoading: string;
147
+ /** Nothing in the inbox. */
148
+ inboxEmpty: string;
149
+ markAllRead: string;
150
+ /** The header's clear button, and the confirm's answer. */
151
+ clearAll: string;
152
+ /** The question asked before the inbox is cleared. */
153
+ clearPrompt: string;
154
+ /** The row that loads the next page. */
155
+ showMore: string;
156
+ /** The bell's name. */
157
+ bellLabel: string;
158
+ /** Read after the bell's name, given the unread count (≥ 1). */
159
+ bellUnread: (count: number) => string;
160
+ /** The bell panel's link to the app's inbox. */
161
+ allNotifications: string;
126
162
 
127
163
  /* ── The unsubscribe page ───────────────────────────────────────────── */
128
164
  unsubscribeTitle: string;
@@ -150,6 +186,9 @@ export const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {
150
186
  typesTitle: "Notify me about",
151
187
  typesDescription: "Choose what you hear about, and how.",
152
188
  byEmail: "By e-mail",
189
+ channelApp: "In the app",
190
+ channelPush: "Push",
191
+ channelEmail: "E-mail",
153
192
  emailUnverified: "Confirm your address to receive these.",
154
193
  digestCadence: "Frequency",
155
194
  digestWeekly: "Weekly",
@@ -158,10 +197,15 @@ export const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {
158
197
  typesEmpty: "There are no notifications for this account.",
159
198
 
160
199
  quietTitle: "Quiet hours",
161
- quietDescription: (inbox) =>
162
- inbox
163
- ? "No push notifications during this window. They still reach your inbox."
164
- : "No push notifications during this window.",
200
+ quietDescription: (inbox, held) =>
201
+ held
202
+ ? inbox
203
+ ? "Push notifications wait during this window and arrive when it ends. They reach your inbox at once."
204
+ : "Push notifications wait during this window and arrive when it ends."
205
+ : inbox
206
+ ? "No push notifications during this window. They still reach your inbox."
207
+ : "No push notifications during this window.",
208
+ quietZone: (zone) => `In your time zone (${zone}), which follows the device you last used.`,
165
209
  quietFrom: "From",
166
210
  quietUntil: "Until",
167
211
  quietIncomplete: "Set both a start and an end time. Quiet hours take effect once both are set.",
@@ -221,6 +265,20 @@ export const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {
221
265
  remove: "Remove",
222
266
  removeItem: (device) => `Remove ${device}`,
223
267
  removePrompt: "Stop sending notifications to this device?",
268
+ deviceTypes: "Types on this device",
269
+ deviceTypesDescription: "Turn a type off to mute it on this device only.",
270
+ deviceTypesEmpty: "No type has push notifications turned on.",
271
+
272
+ inboxTitle: "Inbox",
273
+ inboxLoading: "Loading notifications…",
274
+ inboxEmpty: "You’re all caught up.",
275
+ markAllRead: "Mark all as read",
276
+ clearAll: "Clear all",
277
+ clearPrompt: "Delete all notifications from your inbox?",
278
+ showMore: "Show more",
279
+ bellLabel: "Notifications",
280
+ bellUnread: (count) => `${count} unread`,
281
+ allNotifications: "All notifications",
224
282
 
225
283
  unsubscribeTitle: "E-mail notifications",
226
284
  unsubscribeAbout: (type) => `You get e-mails about: ${type}.`,
@@ -9,14 +9,16 @@ import { formatDate } from "../lib/format";
9
9
  import { Button } from "../components/button";
10
10
  import { Card } from "../components/card";
11
11
  import { Chip } from "../components/chip";
12
+ import { Disclosure } from "../components/disclosure";
12
13
  import { Skeleton } from "../components/skeleton";
14
+ import { Switch } from "../components/switch";
13
15
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
14
16
  import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
15
17
  import { SettingsCardTitle } from "../settings/settings-heading";
16
18
  import { settle, useMounted } from "../account/account-parts";
17
- import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
19
+ import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts";
18
20
  import type { NotificationsLabels } from "./notifications-labels";
19
- import type { PushDeviceItem } from "./notification-types";
21
+ import type { NotificationTypeEntry, NotificationTypesValue, PushDeviceItem } from "./notification-types";
20
22
  import { notifyPushSubscriptionChanged } from "./use-push-device";
21
23
 
22
24
  /**
@@ -70,6 +72,17 @@ export interface PushDevicesSettingProps<Id extends string | number = string>
70
72
  locale?: string;
71
73
  /** The app's own words for a failed removal, or `undefined` for the kit's `failed`. */
72
74
  describeError?: (error: unknown) => ReactNode | undefined;
75
+ /**
76
+ * 0.46 (§10.2): "Types on this device" on each row — a switch per type whose account
77
+ * `push` is on, all on until the device mutes one. Save the device's WHOLE list:
78
+ * `PATCH /notifications/devices/{id} {"muted_types": [...]}`; refresh `devices` once it
79
+ * resolves. Left out, no row has the disclosure.
80
+ */
81
+ onMutedTypesChange?: (id: Id, mutedTypes: string[]) => Promise<unknown> | void;
82
+ /** The app's words for its types (the types card's `types`), for the disclosure. */
83
+ types?: readonly NotificationTypeEntry[];
84
+ /** The settings `GET`'s `types` (the types card's `value`): which types have push on. */
85
+ typeSwitches?: NotificationTypesValue;
73
86
  labels?: LabelOverride<NotificationsLabels>;
74
87
  }
75
88
 
@@ -99,7 +112,13 @@ async function unsubscribeThisBrowser(): Promise<void> {
99
112
  * `PUSH_SUBSCRIPTION_CHANGED` (keksdose `push-devices-card.tsx:83-97`), so the push card
100
113
  * stops saying "on".
101
114
  *
102
- * Under a write lock Remove is a `commit` control exempt from billing.
115
+ * **"Types on this device"** (0.46, §10.2, with `onMutedTypesChange`): a disclosure under
116
+ * each row with a switch per type the account gets by push; turning one off mutes it on
117
+ * that device only — a device can mute types, never add them. Each flip saves the
118
+ * device's whole `muted_types` list.
119
+ *
120
+ * Under a write lock Remove and the type switches are `commit` controls exempt from
121
+ * billing.
103
122
  */
104
123
  export function PushDevicesSetting<Id extends string | number = string>({
105
124
  devices,
@@ -109,11 +128,15 @@ export function PushDevicesSetting<Id extends string | number = string>({
109
128
  formatDate: formatDateProp,
110
129
  locale: localeProp,
111
130
  describeError,
131
+ onMutedTypesChange,
132
+ types,
133
+ typeSwitches,
112
134
  labels: labelsProp,
113
135
  className,
114
136
  ...rest
115
137
  }: PushDevicesSettingProps<Id>) {
116
138
  const labels = useNotificationsLabels(labelsProp);
139
+ const mutedOverlay = useWriteOverlay<string[]>(devices);
117
140
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
118
141
  const locale = useKitLocale(localeProp);
119
142
  const [asking, setAsking] = useState<Id | null>(null);
@@ -146,6 +169,65 @@ export function PushDevicesSetting<Id extends string | number = string>({
146
169
  });
147
170
  };
148
171
 
172
+ // The types a device can mute: those the account gets by push, in the app's order.
173
+ const pushTypes = (types ?? []).filter((entry) => typeSwitches?.[entry.id]?.push === true);
174
+ const mutedOf = (device: PushDeviceItem<Id>): string[] =>
175
+ mutedOverlay.get(String(device.id)) ??
176
+ // Ids the registry dropped (no longer in the settings answer) are ignored.
177
+ (device.muted_types ?? []).filter((id) => !typeSwitches || id in typeSwitches);
178
+ const setMuted = (device: PushDeviceItem<Id>, id: string, on: boolean) => {
179
+ const key = String(device.id);
180
+ if (!onMutedTypesChange || mutedOverlay.pending(key)) return;
181
+ const current = mutedOf(device);
182
+ const next = on ? current.filter((muted) => muted !== id) : [...current.filter((muted) => muted !== id), id];
183
+ setFailure(null);
184
+ mutedOverlay.set(key, next);
185
+ settle(() => onMutedTypesChange(device.id, next)).then(
186
+ () => {
187
+ if (mounted.current) mutedOverlay.settle(key, true);
188
+ },
189
+ (error: unknown) => {
190
+ if (!mounted.current) return;
191
+ mutedOverlay.settle(key, false);
192
+ setFailure(describeFailure(describeError?.(error), labels.failed));
193
+ },
194
+ );
195
+ };
196
+ const typesOf = (device: PushDeviceItem<Id>, name: string): ReactNode => {
197
+ if (!onMutedTypesChange) return null;
198
+ const muted = mutedOf(device);
199
+ return (
200
+ <Disclosure
201
+ variant="bare"
202
+ title={labels.deviceTypes}
203
+ // Every row has one: the name says whose (it starts with the visible words).
204
+ triggerProps={{ "aria-label": `${labels.deviceTypes}: ${name}` }}
205
+ className="text-sm"
206
+ data-device-types=""
207
+ >
208
+ <div className="space-y-2 pt-1 ps-6">
209
+ {pushTypes.length === 0 ? (
210
+ <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesEmpty}</p>
211
+ ) : (
212
+ <>
213
+ <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesDescription}</p>
214
+ {pushTypes.map((entry) => (
215
+ <Switch
216
+ key={entry.id}
217
+ size="sm"
218
+ label={entry.label}
219
+ checked={!muted.includes(entry.id)}
220
+ commit={COMMIT_EXCEPT_BILLING}
221
+ onCheckedChange={(on) => setMuted(device, entry.id, on)}
222
+ />
223
+ ))}
224
+ </>
225
+ )}
226
+ </div>
227
+ </Disclosure>
228
+ );
229
+ };
230
+
149
231
  let body: ReactNode;
150
232
  if (loading || devices === undefined) {
151
233
  body = <Skeleton lines={2} label={labels.devicesLoading} />;
@@ -170,6 +252,7 @@ export function PushDevicesSetting<Id extends string | number = string>({
170
252
  onConfirm={() => remove(device)}
171
253
  labels={labels}
172
254
  cancelLabel={common.cancel}
255
+ types={typesOf(device, describeUserAgent(device.user_agent) ?? labels.unknownDevice)}
173
256
  />
174
257
  ))}
175
258
  </ul>
@@ -202,6 +285,7 @@ function DeviceRow<Id extends string | number>({
202
285
  onConfirm,
203
286
  labels,
204
287
  cancelLabel,
288
+ types,
205
289
  }: {
206
290
  device: PushDeviceItem<Id>;
207
291
  name: string;
@@ -216,6 +300,8 @@ function DeviceRow<Id extends string | number>({
216
300
  onConfirm: () => void;
217
301
  labels: NotificationsLabels;
218
302
  cancelLabel: string;
303
+ /** "Types on this device", under the row (0.46). */
304
+ types: ReactNode;
219
305
  }) {
220
306
  const cancelRef = useRef<HTMLButtonElement>(null);
221
307
  const removeRef = useRef<HTMLButtonElement>(null);
@@ -268,39 +354,42 @@ function DeviceRow<Id extends string | number>({
268
354
  }
269
355
 
270
356
  return (
271
- <li {...row} className="flex items-center gap-3 py-2">
272
- <MonitorSmartphone aria-hidden className="size-4 shrink-0 text-muted" />
273
- <div className="min-w-0 flex-1">
274
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
275
- <span className="min-w-0 break-words text-sm font-medium">{name}</span>
276
- {current && (
277
- <Chip size="sm" tone="brand">
278
- {labels.thisDevice}
279
- </Chip>
280
- )}
357
+ <li {...row} className="space-y-1 py-2">
358
+ <div className="flex items-center gap-3">
359
+ <MonitorSmartphone aria-hidden className="size-4 shrink-0 text-muted" />
360
+ <div className="min-w-0 flex-1">
361
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
362
+ <span className="min-w-0 break-words text-sm font-medium">{name}</span>
363
+ {current && (
364
+ <Chip size="sm" tone="brand">
365
+ {labels.thisDevice}
366
+ </Chip>
367
+ )}
368
+ </div>
369
+ <div className={CARD_DESCRIPTION_CLASS}>
370
+ {labels.added(added)}
371
+ {" · "}
372
+ {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}
373
+ </div>
374
+ {device.failure_count > 0 && <div className="text-xs text-warning">{labels.failures(device.failure_count)}</div>}
281
375
  </div>
282
- <div className={CARD_DESCRIPTION_CLASS}>
283
- {labels.added(added)}
284
- {" · "}
285
- {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}
286
- </div>
287
- {device.failure_count > 0 && <div className="text-xs text-warning">{labels.failures(device.failure_count)}</div>}
376
+ <Button
377
+ ref={removeRef}
378
+ type="button"
379
+ size="sm"
380
+ variant="ghost"
381
+ commit={COMMIT_EXCEPT_BILLING}
382
+ disabled={blocked}
383
+ aria-label={labels.removeItem(name)}
384
+ onClick={() => {
385
+ setReturning(false);
386
+ onAsk();
387
+ }}
388
+ >
389
+ {labels.remove}
390
+ </Button>
288
391
  </div>
289
- <Button
290
- ref={removeRef}
291
- type="button"
292
- size="sm"
293
- variant="ghost"
294
- commit={COMMIT_EXCEPT_BILLING}
295
- disabled={blocked}
296
- aria-label={labels.removeItem(name)}
297
- onClick={() => {
298
- setReturning(false);
299
- onAsk();
300
- }}
301
- >
302
- {labels.remove}
303
- </Button>
392
+ {types}
304
393
  </li>
305
394
  );
306
395
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The service worker's half of Web Push that the kit can own (0.46,
3
+ * docs/notifications-harmonization.md NT:1028): re-registering a subscription the
4
+ * browser ROTATED. A push service may replace a subscription (expiry, a key rotation, a
5
+ * browser update) and fires `pushsubscriptionchange` in the worker; until 0.46 the
6
+ * server kept sending to the old endpoint, got 410, deleted the device, and the person
7
+ * had to turn push on again without knowing why.
8
+ *
9
+ * {@link handlePushSubscriptionChange} listens for the event in the app's worker and
10
+ * calls the app's `replace` — `POST /notifications/devices/replace` (server-kit
11
+ * `PushReplaceIn`): the OLD endpoint and the old subscription's `auth` secret, which only
12
+ * this browser and the server hold, prove the device without a session (a worker has no
13
+ * `Authorization` header to send), and the server moves that row to the new endpoint in
14
+ * place — same account, same device. Open pages are told with a
15
+ * {@link PUSH_SUBSCRIPTION_CHANGED} message, and every mounted `usePushDevice` re-reads.
16
+ *
17
+ * Dependency-free (no React), so an app's worker bundle takes only this file — through
18
+ * the worker-safe subpath `@eifi1/ui-kit/push-worker` (0.46, §10.6 rule 5), not the main
19
+ * barrel, which reaches React and the components:
20
+ *
21
+ * // sw.ts
22
+ * import { handlePushSubscriptionChange } from "@eifi1/ui-kit/push-worker";
23
+ * handlePushSubscriptionChange(self, {
24
+ * pushKey: () => fetch("/api/notifications/push-key").then((r) => r.json()).then((b) => b.key),
25
+ * replace: (body) => fetch("/api/notifications/devices/replace", {
26
+ * method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
27
+ * }).then((r) => { if (!r.ok) throw new Error(String(r.status)); }),
28
+ * });
29
+ */
30
+
31
+ /** What `register` sends: `POST /notifications/devices` (§3.5). */
32
+ export interface PushSubscriptionInput {
33
+ endpoint: string;
34
+ keys: { p256dh: string; auth: string };
35
+ user_agent: string | null;
36
+ }
37
+
38
+ /** What `replace` sends: `POST /notifications/devices/replace` (server-kit
39
+ * `PushReplaceIn`). The endpoint never goes in a URL. */
40
+ export interface PushReplaceInput {
41
+ /** The rotated-away subscription's endpoint. */
42
+ old_endpoint: string;
43
+ /** Its `keys.auth` as registered — the proof that this browser held it. */
44
+ old_auth: string;
45
+ /** The browser's new subscription. */
46
+ subscription: PushSubscriptionInput;
47
+ }
48
+
49
+ /** The message a worker posts to its pages after a rotation, and the window event the
50
+ * hooks re-read on — the same name. */
51
+ export const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
52
+
53
+ /** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
54
+ export function keyBytes(key: string): Uint8Array<ArrayBuffer> {
55
+ const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
56
+ const raw = atob(base64);
57
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
58
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
59
+ return bytes;
60
+ }
61
+
62
+ /** A subscription's register body, or `null` when the browser handed back an incomplete
63
+ * one. */
64
+ export function pushSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput | null {
65
+ const json = subscription.toJSON();
66
+ const p256dh = json.keys?.p256dh;
67
+ const auth = json.keys?.auth;
68
+ if (!json.endpoint || !p256dh || !auth) return null;
69
+ return {
70
+ endpoint: json.endpoint,
71
+ keys: { p256dh, auth },
72
+ user_agent: (typeof navigator !== "undefined" && navigator.userAgent) || null,
73
+ };
74
+ }
75
+
76
+ /** The `pushsubscriptionchange` event as the worker receives it (not in TypeScript's
77
+ * DOM lib). Either subscription may be missing: Chrome often hands neither. */
78
+ export interface PushSubscriptionChangeEventLike {
79
+ readonly oldSubscription?: PushSubscription | null;
80
+ readonly newSubscription?: PushSubscription | null;
81
+ waitUntil?: (promise: Promise<unknown>) => void;
82
+ }
83
+
84
+ export interface PushSubscriptionChangeOptions {
85
+ /** `GET /notifications/push-key` → its `key` — asked only when the browser handed no
86
+ * new subscription and the old one does not carry its key. `""`: push is off. */
87
+ pushKey: () => Promise<string>;
88
+ /** `POST /notifications/devices/replace`. A rejection is the outcome `"failed"`. */
89
+ replace: (input: PushReplaceInput) => Promise<unknown>;
90
+ }
91
+
92
+ /**
93
+ * What a rotation came to: `"replaced"` — the server moved the device; `"no_previous"` —
94
+ * the browser named no old subscription (or one without its keys), so there is nothing
95
+ * to prove the device with and the page shows push as off; `"push_off"` — the server has
96
+ * no key; `"incomplete"` — the new subscription has no keys; `"failed"` — `replace` (or
97
+ * the browser's subscribe) threw.
98
+ */
99
+ export type PushResubscribeOutcome = "replaced" | "no_previous" | "push_off" | "incomplete" | "failed";
100
+
101
+ /**
102
+ * Re-register after `pushsubscriptionchange`: the browser's new subscription — or, when
103
+ * it handed none, the one it holds now, or a fresh one under the old subscription's key
104
+ * (else `pushKey()`) — sent to `replace` with the old endpoint and its `auth`.
105
+ */
106
+ export async function resubscribeAfterChange(
107
+ event: PushSubscriptionChangeEventLike,
108
+ pushManager: PushManager,
109
+ options: PushSubscriptionChangeOptions,
110
+ ): Promise<PushResubscribeOutcome> {
111
+ const old = event.oldSubscription;
112
+ const oldAuth = old?.toJSON().keys?.auth;
113
+ if (!old?.endpoint || !oldAuth) return "no_previous";
114
+ try {
115
+ let next = event.newSubscription ?? (await pushManager.getSubscription());
116
+ if (!next || next.endpoint === old.endpoint) {
117
+ const kept = old.options?.applicationServerKey;
118
+ const key = kept ? null : await options.pushKey();
119
+ if (key === "") return "push_off";
120
+ next = await pushManager.subscribe({
121
+ userVisibleOnly: true,
122
+ applicationServerKey: kept ?? keyBytes(key ?? ""),
123
+ });
124
+ }
125
+ const subscription = pushSubscriptionInput(next);
126
+ if (!subscription) return "incomplete";
127
+ await options.replace({ old_endpoint: old.endpoint, old_auth: oldAuth, subscription });
128
+ return "replaced";
129
+ } catch {
130
+ return "failed";
131
+ }
132
+ }
133
+
134
+ /** The parts of a `ServiceWorkerGlobalScope` this needs. */
135
+ export interface PushWorkerScope {
136
+ addEventListener: (type: "pushsubscriptionchange", listener: (event: PushSubscriptionChangeEventLike) => void) => void;
137
+ registration: { pushManager: PushManager };
138
+ clients?: { matchAll: (options?: { type?: "window"; includeUncontrolled?: boolean }) => Promise<readonly { postMessage: (message: unknown) => void }[]> };
139
+ }
140
+
141
+ /**
142
+ * Listen for `pushsubscriptionchange` in the app's service worker (`self`) and
143
+ * re-register through {@link resubscribeAfterChange}, holding the worker alive until it
144
+ * is done (`waitUntil`). Then every open page gets `{ type: PUSH_SUBSCRIPTION_CHANGED,
145
+ * outcome }`; `usePushDevice` re-reads on it. `onOutcome` is for the app's own logging.
146
+ */
147
+ export function handlePushSubscriptionChange(
148
+ scope: PushWorkerScope,
149
+ options: PushSubscriptionChangeOptions & { onOutcome?: (outcome: PushResubscribeOutcome) => void },
150
+ ): void {
151
+ scope.addEventListener("pushsubscriptionchange", (event) => {
152
+ const done = resubscribeAfterChange(event, scope.registration.pushManager, options).then(async (outcome) => {
153
+ options.onOutcome?.(outcome);
154
+ const pages = (await scope.clients?.matchAll({ type: "window", includeUncontrolled: true })) ?? [];
155
+ for (const page of pages) page.postMessage({ type: PUSH_SUBSCRIPTION_CHANGED, outcome });
156
+ return outcome;
157
+ });
158
+ event.waitUntil?.(done);
159
+ });
160
+ }