@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.
- package/README.md +23 -12
- package/dist/admin/admin-area-labels.d.ts +9 -0
- package/dist/admin/admin-area-labels.js +5 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +16 -1
- package/dist/admin/admin-overview.js +12 -1
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/mail-test-card.d.ts +3 -2
- package/dist/admin/mail-test-card.js +3 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/components/appearance-labels.d.ts +10 -0
- package/dist/components/appearance-labels.js +6 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-head.js +2 -2
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +18 -2
- package/dist/components/data-table-row-actions.js +86 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-shared.js +9 -2
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +43 -2
- package/dist/components/data-table.js +9 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-format-setting.d.ts +29 -0
- package/dist/components/date-format-setting.js +36 -0
- package/dist/components/date-format-setting.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +8 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tabs.d.ts +3 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -0
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +5 -0
- package/dist/components/translation-review-labels.js +2 -0
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +18 -8
- package/dist/components/translation-review.js +64 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/write-lock-labels.js +2 -1
- package/dist/components/write-lock-labels.js.map +1 -1
- package/dist/components/write-lock.d.ts +33 -3
- package/dist/components/write-lock.js +9 -0
- package/dist/components/write-lock.js.map +1 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +4 -2
- package/dist/feedback/feedback-row-detail.js +17 -26
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-swipe.d.ts +18 -0
- package/dist/feedback/feedback-swipe.js +10 -1
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/i18n/german.js +41 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +12 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +40 -6
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +41 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +41 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +41 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +41 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +41 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +3 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/date-format.d.ts +81 -0
- package/dist/lib/date-format.js +56 -0
- package/dist/lib/date-format.js.map +1 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/format.js +5 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +27 -0
- package/dist/notifications/device-zone-report.js +24 -0
- package/dist/notifications/device-zone-report.js.map +1 -0
- package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
- package/dist/notifications/feedback-notification-types-labels.js +4 -1
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +11 -5
- package/dist/notifications/feedback-notification-types.js +2 -1
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +33 -0
- package/dist/notifications/notification-bell.js +74 -0
- package/dist/notifications/notification-bell.js.map +1 -0
- package/dist/notifications/notification-inbox.d.ts +77 -0
- package/dist/notifications/notification-inbox.js +206 -0
- package/dist/notifications/notification-inbox.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +6 -4
- package/dist/notifications/notification-types-setting.js +103 -45
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +19 -7
- package/dist/notifications/notifications-labels.d.ts +41 -7
- package/dist/notifications/notifications-labels.js +18 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-devices-setting.d.ts +20 -3
- package/dist/notifications/push-devices-setting.js +93 -33
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.d.ts +108 -0
- package/dist/notifications/push-worker.js +62 -0
- package/dist/notifications/push-worker.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +29 -17
- package/dist/notifications/quiet-hours-setting.js +9 -41
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +5 -2
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/notifications/use-push-device.d.ts +4 -17
- package/dist/notifications/use-push-device.js +7 -15
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/local-first.d.ts +33 -0
- package/dist/offline/local-first.js +39 -0
- package/dist/offline/local-first.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +26 -1
- package/dist/offline/query-persistence.js +27 -23
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline/service-worker-updates.d.ts +46 -0
- package/dist/offline/service-worker-updates.js +39 -0
- package/dist/offline/service-worker-updates.js.map +1 -0
- package/dist/offline.d.ts +5 -2
- package/dist/offline.js +11 -2
- package/dist/offline.js.map +1 -1
- package/dist/push-worker.d.ts +2 -0
- package/dist/push-worker.js +14 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/shell/account-menu-text-size.d.ts +33 -0
- package/dist/shell/account-menu-text-size.js +51 -0
- package/dist/shell/account-menu-text-size.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +6 -2
- package/dist/shell/top-bar-actions.js +1 -0
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/sync/db.d.ts +76 -0
- package/dist/sync/db.js +24 -0
- package/dist/sync/db.js.map +1 -0
- package/dist/sync/engine.d.ts +124 -0
- package/dist/sync/engine.js +474 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hooks.d.ts +30 -0
- package/dist/sync/hooks.js +67 -0
- package/dist/sync/hooks.js.map +1 -0
- package/dist/sync/testing.d.ts +28 -0
- package/dist/sync/testing.js +41 -0
- package/dist/sync/testing.js.map +1 -0
- package/dist/sync/wire.d.ts +50 -0
- package/dist/sync/wire.js +2 -0
- package/dist/sync/wire.js.map +1 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.js +22 -0
- package/dist/sync.js.map +1 -0
- package/dist/tour/tour-labels.js +1 -0
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +30 -0
- package/dist/tour/tour.js +50 -6
- package/dist/tour/tour.js.map +1 -1
- package/package.json +19 -1
- package/src/admin/admin-area-labels.ts +13 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card.tsx +7 -3
- package/src/components/appearance-labels.ts +16 -0
- package/src/components/data-table-column-settings.tsx +2 -2
- package/src/components/data-table-head.tsx +2 -2
- package/src/components/data-table-row-actions.tsx +118 -2
- package/src/components/data-table-shared.tsx +21 -2
- package/src/components/data-table.tsx +51 -6
- package/src/components/date-format-setting.tsx +64 -0
- package/src/components/swipeable-row.tsx +8 -0
- package/src/components/tabs.tsx +4 -3
- package/src/components/translation-review-editor.tsx +2 -0
- package/src/components/translation-review-labels.ts +7 -0
- package/src/components/translation-review.tsx +102 -14
- package/src/components/write-lock-labels.ts +1 -0
- package/src/components/write-lock.tsx +36 -3
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +18 -23
- package/src/feedback/feedback-swipe.tsx +28 -1
- package/src/i18n/german.ts +44 -4
- package/src/i18n/kit-labels.tsx +18 -1
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/es.ts +44 -4
- package/src/i18n/locales/fr.ts +44 -4
- package/src/i18n/locales/hu.ts +44 -4
- package/src/i18n/locales/it.ts +44 -4
- package/src/i18n/locales/zh.ts +44 -2
- package/src/i18n/review.ts +3 -1
- package/src/index.ts +42 -0
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +10 -4
- package/src/notifications/device-zone-report.ts +54 -0
- package/src/notifications/feedback-notification-types-labels.ts +10 -1
- package/src/notifications/feedback-notification-types.ts +12 -5
- package/src/notifications/notification-bell.tsx +116 -0
- package/src/notifications/notification-inbox.tsx +304 -0
- package/src/notifications/notification-types-setting.tsx +151 -55
- package/src/notifications/notification-types.ts +20 -8
- package/src/notifications/notifications-labels.ts +69 -11
- package/src/notifications/push-devices-setting.tsx +123 -34
- package/src/notifications/push-worker.ts +160 -0
- package/src/notifications/quiet-hours-setting.tsx +41 -58
- package/src/notifications/time-zone-select.tsx +5 -2
- package/src/notifications/use-push-device.ts +20 -33
- package/src/offline/local-first.ts +76 -0
- package/src/offline/query-persistence.ts +57 -24
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline.ts +7 -1
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +3 -0
- package/src/sync/db.ts +103 -0
- package/src/sync/engine.ts +699 -0
- package/src/sync/hooks.ts +93 -0
- package/src/sync/testing.ts +73 -0
- package/src/sync/wire.ts +61 -0
- package/src/sync.ts +29 -0
- package/src/tour/tour-labels.ts +1 -0
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
61
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
163
|
-
?
|
|
164
|
-
|
|
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
|
-
*
|
|
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="
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
<div className="
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
<
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
+
}
|