@eifi1/ui-kit 0.44.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 +29 -14
- package/dist/admin/admin-action-log-labels.js +9 -0
- package/dist/admin/admin-action-log-labels.js.map +1 -1
- package/dist/admin/admin-action-log.d.ts +18 -1
- package/dist/admin/admin-action-log.js +25 -16
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.d.ts +15 -0
- package/dist/admin/admin-area-labels.js +6 -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-labels.d.ts +3 -0
- package/dist/admin/mail-test-card-labels.js +17 -0
- package/dist/admin/mail-test-card-labels.js.map +1 -0
- package/dist/admin/mail-test-card.d.ts +75 -0
- package/dist/admin/mail-test-card.js +82 -0
- package/dist/admin/mail-test-card.js.map +1 -0
- 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-mobile.js +17 -2
- package/dist/components/data-table-mobile.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.d.ts +2 -0
- package/dist/components/data-table-shared.js +17 -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 +10 -10
- 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/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +81 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +25 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +97 -7
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +81 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +81 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +81 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +81 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +81 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +16 -2
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.js +40 -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 +5 -1
- package/dist/lib/format.js +11 -5
- 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/pwa-update-prompt-labels.d.ts +2 -0
- package/dist/offline/pwa-update-prompt-labels.js +9 -0
- package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
- package/dist/offline/pwa-update-prompt.d.ts +32 -0
- package/dist/offline/pwa-update-prompt.js +36 -0
- package/dist/offline/pwa-update-prompt.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +125 -0
- package/dist/offline/query-persistence.js +183 -0
- package/dist/offline/query-persistence.js.map +1 -0
- 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/shared-computer-labels.d.ts +2 -0
- package/dist/offline/shared-computer-labels.js +9 -0
- package/dist/offline/shared-computer-labels.js.map +1 -0
- package/dist/offline/shared-computer.d.ts +44 -0
- package/dist/offline/shared-computer.js +51 -0
- package/dist/offline/shared-computer.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
- package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
- package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
- package/dist/offline/sign-out-pending-dialog.js +66 -0
- package/dist/offline/sign-out-pending-dialog.js.map +1 -0
- package/dist/offline.d.ts +11 -0
- package/dist/offline.js +33 -0
- package/dist/offline.js.map +1 -0
- 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/admin-menu.d.ts +24 -0
- package/dist/shell/admin-menu.js +12 -3
- package/dist/shell/admin-menu.js.map +1 -1
- 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 +4 -0
- package/dist/shell.js +4 -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-catalogue.d.ts +7 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- 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 +54 -8
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +99 -1
- package/dist/tour/use-tour-progress.js +228 -12
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +2 -0
- package/dist/tour.js.map +1 -1
- package/package.json +43 -1
- package/src/admin/admin-action-log-labels.ts +9 -0
- package/src/admin/admin-action-log.tsx +41 -5
- package/src/admin/admin-area-labels.ts +20 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card-labels.ts +19 -0
- package/src/admin/mail-test-card.tsx +190 -0
- 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-mobile.tsx +26 -3
- package/src/components/data-table-row-actions.tsx +118 -2
- package/src/components/data-table-shared.tsx +39 -3
- package/src/components/data-table.tsx +52 -21
- 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/defaults.ts +8 -0
- package/src/i18n/german.ts +90 -4
- package/src/i18n/kit-labels.tsx +31 -1
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +90 -4
- package/src/i18n/locales/fr.ts +90 -4
- package/src/i18n/locales/hu.ts +86 -4
- package/src/i18n/locales/it.ts +90 -4
- package/src/i18n/locales/zh.ts +84 -2
- package/src/i18n/review.ts +15 -1
- package/src/index.ts +45 -0
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +23 -6
- 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/pwa-update-prompt-labels.ts +9 -0
- package/src/offline/pwa-update-prompt.tsx +72 -0
- package/src/offline/query-persistence.ts +356 -0
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline/shared-computer-labels.ts +8 -0
- package/src/offline/shared-computer.tsx +87 -0
- package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
- package/src/offline/sign-out-pending-dialog.tsx +135 -0
- package/src/offline.ts +32 -0
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/admin-menu.tsx +40 -3
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +7 -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-catalogue.tsx +7 -1
- package/src/tour/tour-labels.ts +1 -0
- package/src/tour/tour.tsx +105 -9
- package/src/tour/use-tour-progress.ts +378 -12
- package/src/tour.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,6 @@ import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import type { LabelOverride } from "../i18n/kit-labels";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
-
import { Button } from "../components/button";
|
|
7
6
|
import { Card } from "../components/card";
|
|
8
7
|
import { TimeInput } from "../components/time-input";
|
|
9
8
|
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
@@ -13,9 +12,9 @@ import { settle, useMounted } from "../account/account-parts";
|
|
|
13
12
|
import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts";
|
|
14
13
|
import type { NotificationsLabels } from "./notifications-labels";
|
|
15
14
|
import type { NotificationSettingsPatch } from "./notification-types";
|
|
16
|
-
import {
|
|
15
|
+
import { zoneName } from "./time-zone-select";
|
|
17
16
|
|
|
18
|
-
/** The zone the server applies while
|
|
17
|
+
/** The zone the server applies while no device ever reported one (§10.3): every app's. */
|
|
19
18
|
const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
|
|
20
19
|
|
|
21
20
|
export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div">, "children" | "onChange"> {
|
|
@@ -23,17 +22,29 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
|
|
|
23
22
|
start: string | null;
|
|
24
23
|
/** The `GET`'s `quiet_hours_end`. */
|
|
25
24
|
end: string | null;
|
|
26
|
-
/** The
|
|
27
|
-
|
|
25
|
+
/** The zone the 0.36 card showed, an IANA name, or `null`.
|
|
26
|
+
* @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.
|
|
27
|
+
* Shown read-only while `zone` is left out; the card no longer offers a picker. */
|
|
28
|
+
timezone?: string | null;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
* the
|
|
31
|
-
*
|
|
30
|
+
* 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s
|
|
31
|
+
* `device_timezone`, the zone of the device last used; `null` while no device has
|
|
32
|
+
* reported one, and the card then names `fallbackZone`, the zone the server applies.
|
|
33
|
+
* Shown read-only once a time is set ("In your time zone (Europe / Zurich), which follows
|
|
34
|
+
* the device you last used."). No picker: the zone is never chosen.
|
|
35
|
+
*/
|
|
36
|
+
zone?: string | null;
|
|
37
|
+
/** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs
|
|
38
|
+
* the notifications tick); the description says so. Default `false`: dropped. */
|
|
39
|
+
held?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.
|
|
42
|
+
* Never a zone. The field shows the new value while the call runs and goes back if it
|
|
32
43
|
* rejects.
|
|
33
44
|
*/
|
|
34
45
|
onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
|
|
35
|
-
/** The zone
|
|
36
|
-
* Default `"Europe/Zurich"`, every app's
|
|
46
|
+
/** The zone named while neither `zone` nor `timezone` names one: the one the server
|
|
47
|
+
* applies. Default `"Europe/Zurich"`, every app's. */
|
|
37
48
|
fallbackZone?: string;
|
|
38
49
|
/** The app keeps an inbox, and the description says a held push still reaches it. */
|
|
39
50
|
inbox?: boolean;
|
|
@@ -43,30 +54,32 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
|
|
|
43
54
|
labels?: LabelOverride<NotificationsLabels>;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
type QuietField = "quiet_hours_start" | "quiet_hours_end"
|
|
57
|
+
type QuietField = "quiet_hours_start" | "quiet_hours_end";
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local
|
|
50
61
|
* times in the account's zone, which may cross midnight, when no push of a machine-sent
|
|
51
|
-
* type is sent. A held push is dropped,
|
|
52
|
-
* usual. keksdose's block, as a card of its
|
|
62
|
+
* type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the
|
|
63
|
+
* window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its
|
|
64
|
+
* own.
|
|
65
|
+
*
|
|
66
|
+
* **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic
|
|
67
|
+
* only** — the zone of the device last used, else `Europe/Zurich`. The card names it
|
|
68
|
+
* read-only and never sends one; there is no picker and no "Use it".
|
|
53
69
|
*
|
|
54
70
|
* - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
|
|
55
71
|
* one is set the card says so: the window fails open until both are.
|
|
56
|
-
* - **The zone** shows while a time is set
|
|
57
|
-
*
|
|
58
|
-
* (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows
|
|
59
|
-
* `fallbackZone` — the zone the server then applies — never the device's (keksdose's
|
|
60
|
-
* card showed the device's zone while its server applied UTC).
|
|
61
|
-
* - **"This device is in …"** with "Use it" when the device's zone is not the one in
|
|
62
|
-
* effect. Never written on its own: a zone is written by a person's pick (§2.6).
|
|
72
|
+
* - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else
|
|
73
|
+
* `fallbackZone`.
|
|
63
74
|
*
|
|
64
75
|
* Under a write lock the fields are `commit` controls exempt from billing.
|
|
65
76
|
*/
|
|
66
77
|
export function QuietHoursSetting({
|
|
67
78
|
start,
|
|
68
79
|
end,
|
|
69
|
-
timezone,
|
|
80
|
+
timezone = null,
|
|
81
|
+
zone,
|
|
82
|
+
held = false,
|
|
70
83
|
onChange,
|
|
71
84
|
fallbackZone = DEFAULT_FALLBACK_ZONE,
|
|
72
85
|
inbox = false,
|
|
@@ -77,7 +90,7 @@ export function QuietHoursSetting({
|
|
|
77
90
|
...rest
|
|
78
91
|
}: QuietHoursSettingProps) {
|
|
79
92
|
const labels = useNotificationsLabels(labelsProp);
|
|
80
|
-
const overlay = useWriteOverlay<string | null>(`${start}|${end}
|
|
93
|
+
const overlay = useWriteOverlay<string | null>(`${start}|${end}`);
|
|
81
94
|
const [failure, setFailure] = useState<ReactNode>(null);
|
|
82
95
|
const mounted = useMounted();
|
|
83
96
|
const id = useId();
|
|
@@ -86,9 +99,7 @@ export function QuietHoursSetting({
|
|
|
86
99
|
overlay.has(field) ? (overlay.get(field) ?? null) : server;
|
|
87
100
|
const startShown = shown("quiet_hours_start", start);
|
|
88
101
|
const endShown = shown("quiet_hours_end", end);
|
|
89
|
-
const
|
|
90
|
-
const effectiveZone = zoneShown ?? fallbackZone;
|
|
91
|
-
const device = deviceTimeZone();
|
|
102
|
+
const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;
|
|
92
103
|
|
|
93
104
|
const save = (patch: NotificationSettingsPatch) => {
|
|
94
105
|
const fields = Object.keys(patch) as QuietField[];
|
|
@@ -109,9 +120,7 @@ export function QuietHoursSetting({
|
|
|
109
120
|
const saveTime = (field: "quiet_hours_start" | "quiet_hours_end", value: string) => {
|
|
110
121
|
const next = value || null;
|
|
111
122
|
if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
|
|
112
|
-
|
|
113
|
-
if (next && zoneShown === null) patch.timezone = device;
|
|
114
|
-
save(patch);
|
|
123
|
+
save({ [field]: next });
|
|
115
124
|
};
|
|
116
125
|
|
|
117
126
|
const anySet = Boolean(startShown || endShown);
|
|
@@ -121,7 +130,7 @@ export function QuietHoursSetting({
|
|
|
121
130
|
<Card {...rest} className={cn("p-4 space-y-3", className)}>
|
|
122
131
|
<div>
|
|
123
132
|
<SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>
|
|
124
|
-
<div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox)}</div>
|
|
133
|
+
<div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>
|
|
125
134
|
</div>
|
|
126
135
|
<div className="flex flex-wrap items-end gap-3">
|
|
127
136
|
<TimeInput
|
|
@@ -149,35 +158,9 @@ export function QuietHoursSetting({
|
|
|
149
158
|
</p>
|
|
150
159
|
)}
|
|
151
160
|
{anySet && (
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
value={effectiveZone}
|
|
156
|
-
disabled={disabled}
|
|
157
|
-
commit={COMMIT_EXCEPT_BILLING}
|
|
158
|
-
onChange={(zone) => save({ timezone: zone })}
|
|
159
|
-
className="max-w-72"
|
|
160
|
-
labels={labelsProp}
|
|
161
|
-
/>
|
|
162
|
-
{device !== effectiveZone && (
|
|
163
|
-
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
164
|
-
<span id={`${id}-device`} className={CARD_DESCRIPTION_CLASS}>
|
|
165
|
-
{labels.deviceZone(zoneName(device, labels.regions))}
|
|
166
|
-
</span>
|
|
167
|
-
<Button
|
|
168
|
-
type="button"
|
|
169
|
-
size="sm"
|
|
170
|
-
variant="link"
|
|
171
|
-
disabled={disabled}
|
|
172
|
-
commit={COMMIT_EXCEPT_BILLING}
|
|
173
|
-
aria-describedby={`${id}-device`}
|
|
174
|
-
onClick={() => save({ timezone: device })}
|
|
175
|
-
>
|
|
176
|
-
{labels.useDeviceZone}
|
|
177
|
-
</Button>
|
|
178
|
-
</div>
|
|
179
|
-
)}
|
|
180
|
-
</div>
|
|
161
|
+
<p className={CARD_DESCRIPTION_CLASS} data-quiet-zone="">
|
|
162
|
+
{labels.quietZone(zoneName(accountZone, labels.regions))}
|
|
163
|
+
</p>
|
|
181
164
|
)}
|
|
182
165
|
<FailureLine>{failure}</FailureLine>
|
|
183
166
|
</Card>
|
|
@@ -10,8 +10,8 @@ import type { NotificationRegionLabels, NotificationsLabels } from "./notificati
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
|
|
13
|
-
* the zone
|
|
14
|
-
*
|
|
13
|
+
* the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
|
|
14
|
+
* only).
|
|
15
15
|
*/
|
|
16
16
|
export function deviceTimeZone(): string {
|
|
17
17
|
try {
|
|
@@ -40,6 +40,7 @@ function cityOf(rest: string): string {
|
|
|
40
40
|
* rather than trailing the last group as if it were Pacific. `current` is kept even when
|
|
41
41
|
* the runtime does not list it (an old alias such as "Europe/Kiev"): a picker without its
|
|
42
42
|
* own value would show an empty field.
|
|
43
|
+
* @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
|
|
43
44
|
*/
|
|
44
45
|
export function timeZoneOptions(
|
|
45
46
|
current: string,
|
|
@@ -73,6 +74,7 @@ export function zoneName(zone: string, regions: NotificationRegionLabels): strin
|
|
|
73
74
|
return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47. */
|
|
76
78
|
export interface TimeZoneSelectProps {
|
|
77
79
|
/** The IANA zone. Not clearable: a zone can be changed, never emptied. */
|
|
78
80
|
value: string;
|
|
@@ -95,6 +97,7 @@ export interface TimeZoneSelectProps {
|
|
|
95
97
|
* a search box instead of a 400-row wheel. Rows are the city under its region, the region
|
|
96
98
|
* in the kit's words (`labels.regions`); a zone with no region first; a stored zone the
|
|
97
99
|
* runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.
|
|
100
|
+
* @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
|
|
98
101
|
*/
|
|
99
102
|
export function TimeZoneSelect({
|
|
100
103
|
value,
|