@eifi1/ui-kit 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -5
- package/dist/admin/role-select.d.ts +3 -2
- package/dist/admin/role-select.js +2 -0
- package/dist/admin/role-select.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +2 -2
- package/dist/components/autocomplete.js +2 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-look.d.ts +1 -1
- package/dist/components/button-look.js +9 -4
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/checkbox-group.d.ts +5 -2
- package/dist/components/checkbox-group.js +15 -4
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/column-mapper.d.ts +5 -3
- package/dist/components/column-mapper.js +2 -0
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +2 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/country-select.d.ts +3 -3
- package/dist/components/country-select.js +3 -0
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-select.d.ts +3 -2
- package/dist/components/currency-select.js +10 -1
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/date-field.d.ts +2 -1
- package/dist/components/date-field.js.map +1 -1
- package/dist/components/date-picker.js +3 -0
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js +3 -0
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +2 -2
- package/dist/components/entity-combobox.js +2 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/field-parts.d.ts +66 -11
- package/dist/components/field-parts.js +32 -10
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +3 -2
- package/dist/components/field-strip.js +2 -1
- package/dist/components/field-strip.js.map +1 -1
- package/dist/components/field.d.ts +3 -2
- package/dist/components/field.js +24 -15
- package/dist/components/field.js.map +1 -1
- package/dist/components/help-popover.d.ts +9 -7
- package/dist/components/help-popover.js.map +1 -1
- package/dist/components/icon-picker.d.ts +3 -2
- package/dist/components/icon-picker.js +3 -0
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-entity-combobox.d.ts +2 -2
- package/dist/components/inline-entity-combobox.js +2 -1
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.d.ts +2 -2
- package/dist/components/input.js +2 -1
- package/dist/components/input.js.map +1 -1
- package/dist/components/month-picker.d.ts +2 -1
- package/dist/components/month-picker.js +8 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +2 -2
- package/dist/components/multi-entity-combobox.js +2 -1
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +2 -2
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-input.d.ts +2 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numeric-field.d.ts +5 -2
- package/dist/components/numeric-field.js +4 -1
- package/dist/components/numeric-field.js.map +1 -1
- package/dist/components/one-time-code-input.d.ts +2 -1
- package/dist/components/one-time-code-input.js +2 -1
- package/dist/components/one-time-code-input.js.map +1 -1
- package/dist/components/phone-input.d.ts +1 -1
- package/dist/components/phone-input.js +4 -0
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/popover.js +1 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/select.d.ts +4 -3
- package/dist/components/select.js +2 -1
- package/dist/components/select.js.map +1 -1
- package/dist/components/slider.d.ts +3 -2
- package/dist/components/slider.js +7 -1
- package/dist/components/slider.js.map +1 -1
- package/dist/components/stat-tile.js +2 -11
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -2
- package/dist/components/swatch-picker.js +3 -0
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/text-link.js +3 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/textarea.d.ts +2 -1
- package/dist/components/textarea.js +9 -3
- package/dist/components/textarea.js.map +1 -1
- package/dist/components/toggle-group.d.ts +2 -2
- package/dist/components/toggle-group.js +7 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +97 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +21 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +103 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +95 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +96 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +95 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +96 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +95 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +13 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js +3 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +21 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/device-settings.d.ts +72 -0
- package/dist/lib/device-settings.js +64 -0
- package/dist/lib/device-settings.js.map +1 -0
- package/dist/lib/plain-text.d.ts +11 -0
- package/dist/lib/plain-text.js +16 -0
- package/dist/lib/plain-text.js.map +1 -0
- package/dist/lib/safe-storage.d.ts +6 -0
- package/dist/lib/safe-storage.js +11 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/notifications/notification-parts.d.ts +39 -0
- package/dist/notifications/notification-parts.js +47 -0
- package/dist/notifications/notification-parts.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +62 -0
- package/dist/notifications/notification-types-setting.js +192 -0
- package/dist/notifications/notification-types-setting.js.map +1 -0
- package/dist/notifications/notification-types.d.ts +75 -0
- package/dist/notifications/notification-types.js +2 -0
- package/dist/notifications/notification-types.js.map +1 -0
- package/dist/notifications/notifications-labels.d.ts +131 -0
- package/dist/notifications/notifications-labels.js +83 -0
- package/dist/notifications/notifications-labels.js.map +1 -0
- package/dist/notifications/push-device-setting.d.ts +37 -0
- package/dist/notifications/push-device-setting.js +164 -0
- package/dist/notifications/push-device-setting.js.map +1 -0
- package/dist/notifications/push-devices-setting.d.ts +44 -0
- package/dist/notifications/push-devices-setting.js +203 -0
- package/dist/notifications/push-devices-setting.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +47 -0
- package/dist/notifications/quiet-hours-setting.js +131 -0
- package/dist/notifications/quiet-hours-setting.js.map +1 -0
- package/dist/notifications/time-zone-select.d.ts +50 -0
- package/dist/notifications/time-zone-select.js +76 -0
- package/dist/notifications/time-zone-select.js.map +1 -0
- package/dist/notifications/unsubscribe-panel.d.ts +53 -0
- package/dist/notifications/unsubscribe-panel.js +139 -0
- package/dist/notifications/unsubscribe-panel.js.map +1 -0
- package/dist/notifications/use-push-device.d.ts +122 -0
- package/dist/notifications/use-push-device.js +230 -0
- package/dist/notifications/use-push-device.js.map +1 -0
- package/dist/rhf/fields.d.ts +26 -19
- package/dist/rhf/fields.js +51 -22
- package/dist/rhf/fields.js.map +1 -1
- package/dist/settings/device-reset-setting-labels.d.ts +3 -0
- package/dist/settings/device-reset-setting-labels.js +15 -0
- package/dist/settings/device-reset-setting-labels.js.map +1 -0
- package/dist/settings/device-reset-setting.d.ts +58 -0
- package/dist/settings/device-reset-setting.js +51 -0
- package/dist/settings/device-reset-setting.js.map +1 -0
- package/dist/shell/app-shell.js +5 -0
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/theme/contrast-tokens.d.ts +42 -4
- package/dist/theme/contrast-tokens.js +56 -9
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/contrast.d.ts +4 -2
- package/dist/theme/contrast.js +13 -0
- package/dist/theme/contrast.js.map +1 -1
- package/dist/theme/palette-derive.d.ts +15 -1
- package/dist/theme/palette-derive.js +37 -8
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +2 -1
- package/dist/theme/palette-presets.js +5 -5
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +6 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/text-size.js +6 -0
- package/dist/theme/text-size.js.map +1 -1
- package/dist/theme/theme-store.js +6 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/role-select.tsx +4 -1
- package/src/components/amount-input.tsx +2 -1
- package/src/components/autocomplete.tsx +5 -2
- package/src/components/button-look.ts +15 -5
- package/src/components/checkbox-group.tsx +22 -4
- package/src/components/column-mapper.tsx +6 -2
- package/src/components/combobox.tsx +5 -2
- package/src/components/country-select.tsx +6 -1
- package/src/components/currency-select.tsx +12 -3
- package/src/components/date-field.tsx +2 -2
- package/src/components/date-picker.tsx +3 -0
- package/src/components/date-range-picker.tsx +3 -0
- package/src/components/entity-combobox.tsx +5 -2
- package/src/components/field-parts.tsx +107 -19
- package/src/components/field-strip.tsx +4 -3
- package/src/components/field.tsx +28 -15
- package/src/components/help-popover.tsx +9 -7
- package/src/components/icon-picker.tsx +8 -3
- package/src/components/inline-entity-combobox.tsx +5 -2
- package/src/components/input.tsx +4 -2
- package/src/components/month-picker.tsx +10 -3
- package/src/components/multi-entity-combobox.tsx +5 -2
- package/src/components/multi-select.tsx +6 -1
- package/src/components/number-input.tsx +2 -2
- package/src/components/numeric-field.ts +10 -2
- package/src/components/one-time-code-input.tsx +5 -3
- package/src/components/phone-input.tsx +4 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/select.tsx +6 -3
- package/src/components/slider.tsx +10 -3
- package/src/components/stat-tile.tsx +3 -18
- package/src/components/swatch-picker.tsx +9 -4
- package/src/components/text-link.tsx +3 -1
- package/src/components/textarea.tsx +11 -4
- package/src/components/toggle-group.tsx +21 -5
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +113 -0
- package/src/i18n/kit-labels.tsx +51 -1
- package/src/i18n/locales/en.ts +6 -0
- package/src/i18n/locales/es.ts +106 -0
- package/src/i18n/locales/fr.ts +106 -0
- package/src/i18n/locales/hu.ts +104 -0
- package/src/i18n/locales/it.ts +105 -0
- package/src/i18n/locales/zh.ts +96 -0
- package/src/i18n/review.ts +13 -0
- package/src/i18n/shared-labels.ts +2 -0
- package/src/index.ts +50 -0
- package/src/lib/device-settings.ts +146 -0
- package/src/lib/plain-text.ts +22 -0
- package/src/lib/safe-storage.ts +16 -0
- package/src/notifications/notification-parts.tsx +83 -0
- package/src/notifications/notification-types-setting.tsx +309 -0
- package/src/notifications/notification-types.ts +80 -0
- package/src/notifications/notifications-labels.ts +238 -0
- package/src/notifications/push-device-setting.tsx +224 -0
- package/src/notifications/push-devices-setting.tsx +306 -0
- package/src/notifications/quiet-hours-setting.tsx +185 -0
- package/src/notifications/time-zone-select.tsx +126 -0
- package/src/notifications/unsubscribe-panel.tsx +203 -0
- package/src/notifications/use-push-device.ts +392 -0
- package/src/rhf/fields.tsx +77 -29
- package/src/settings/device-reset-setting-labels.ts +20 -0
- package/src/settings/device-reset-setting.tsx +119 -0
- package/src/shell/app-shell.tsx +8 -0
- package/src/theme/contrast-tokens.ts +106 -12
- package/src/theme/contrast.ts +30 -4
- package/src/theme/palette-derive.ts +68 -7
- package/src/theme/palette-presets.ts +9 -6
- package/src/theme/palette-store.ts +12 -0
- package/src/theme/text-size.ts +8 -0
- package/src/theme/theme-store.ts +10 -0
- package/tokens.css +38 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { Button } from "../components/button.js";
|
|
6
|
+
import { Card } from "../components/card.js";
|
|
7
|
+
import { TimeInput } from "../components/time-input.js";
|
|
8
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock.js";
|
|
9
|
+
import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes.js";
|
|
10
|
+
import { SettingsCardTitle } from "../settings/settings-heading.js";
|
|
11
|
+
import { settle, useMounted } from "../account/account-parts.js";
|
|
12
|
+
import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts.js";
|
|
13
|
+
import { deviceTimeZone, TimeZoneSelect, zoneName } from "./time-zone-select.js";
|
|
14
|
+
const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
|
|
15
|
+
function QuietHoursSetting({
|
|
16
|
+
start,
|
|
17
|
+
end,
|
|
18
|
+
timezone,
|
|
19
|
+
onChange,
|
|
20
|
+
fallbackZone = DEFAULT_FALLBACK_ZONE,
|
|
21
|
+
inbox = false,
|
|
22
|
+
disabled = false,
|
|
23
|
+
describeError,
|
|
24
|
+
labels: labelsProp,
|
|
25
|
+
className,
|
|
26
|
+
...rest
|
|
27
|
+
}) {
|
|
28
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
29
|
+
const overlay = useWriteOverlay(`${start}|${end}|${timezone}`);
|
|
30
|
+
const [failure, setFailure] = useState(null);
|
|
31
|
+
const mounted = useMounted();
|
|
32
|
+
const id = useId();
|
|
33
|
+
const shown = (field, server) => overlay.has(field) ? overlay.get(field) ?? null : server;
|
|
34
|
+
const startShown = shown("quiet_hours_start", start);
|
|
35
|
+
const endShown = shown("quiet_hours_end", end);
|
|
36
|
+
const zoneShown = shown("timezone", timezone);
|
|
37
|
+
const effectiveZone = zoneShown ?? fallbackZone;
|
|
38
|
+
const device = deviceTimeZone();
|
|
39
|
+
const save = (patch) => {
|
|
40
|
+
const fields = Object.keys(patch);
|
|
41
|
+
setFailure(null);
|
|
42
|
+
for (const field of fields) overlay.set(field, patch[field] ?? null);
|
|
43
|
+
settle(() => onChange(patch)).then(
|
|
44
|
+
() => {
|
|
45
|
+
if (mounted.current) for (const field of fields) overlay.settle(field, true);
|
|
46
|
+
},
|
|
47
|
+
(error) => {
|
|
48
|
+
if (!mounted.current) return;
|
|
49
|
+
for (const field of fields) overlay.settle(field, false);
|
|
50
|
+
setFailure(describeFailure(describeError?.(error), labels.failed));
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
const saveTime = (field, value) => {
|
|
55
|
+
const next = value || null;
|
|
56
|
+
if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
|
|
57
|
+
const patch = { [field]: next };
|
|
58
|
+
if (next && zoneShown === null) patch.timezone = device;
|
|
59
|
+
save(patch);
|
|
60
|
+
};
|
|
61
|
+
const anySet = Boolean(startShown || endShown);
|
|
62
|
+
const halfSet = Boolean(startShown) !== Boolean(endShown);
|
|
63
|
+
return /* @__PURE__ */ jsxs(Card, { ...rest, className: cn("p-4 space-y-3", className), children: [
|
|
64
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
65
|
+
/* @__PURE__ */ jsx(SettingsCardTitle, { children: labels.quietTitle }),
|
|
66
|
+
/* @__PURE__ */ jsx("div", { className: CARD_DESCRIPTION_CLASS, children: labels.quietDescription(inbox) })
|
|
67
|
+
] }),
|
|
68
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-end gap-3", children: [
|
|
69
|
+
/* @__PURE__ */ jsx(
|
|
70
|
+
TimeInput,
|
|
71
|
+
{
|
|
72
|
+
id: `${id}-start`,
|
|
73
|
+
label: labels.quietFrom,
|
|
74
|
+
value: startShown ?? "",
|
|
75
|
+
disabled,
|
|
76
|
+
commit: COMMIT_EXCEPT_BILLING,
|
|
77
|
+
onValueChange: (value) => saveTime("quiet_hours_start", value),
|
|
78
|
+
className: "w-36"
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
/* @__PURE__ */ jsx(
|
|
82
|
+
TimeInput,
|
|
83
|
+
{
|
|
84
|
+
id: `${id}-end`,
|
|
85
|
+
label: labels.quietUntil,
|
|
86
|
+
value: endShown ?? "",
|
|
87
|
+
disabled,
|
|
88
|
+
commit: COMMIT_EXCEPT_BILLING,
|
|
89
|
+
onValueChange: (value) => saveTime("quiet_hours_end", value),
|
|
90
|
+
className: "w-36"
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
] }),
|
|
94
|
+
halfSet && /* @__PURE__ */ jsx("p", { className: "text-xs text-warning", "data-quiet-incomplete": "", children: labels.quietIncomplete }),
|
|
95
|
+
anySet && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
96
|
+
/* @__PURE__ */ jsx(
|
|
97
|
+
TimeZoneSelect,
|
|
98
|
+
{
|
|
99
|
+
id: `${id}-zone`,
|
|
100
|
+
value: effectiveZone,
|
|
101
|
+
disabled,
|
|
102
|
+
commit: COMMIT_EXCEPT_BILLING,
|
|
103
|
+
onChange: (zone) => save({ timezone: zone }),
|
|
104
|
+
className: "max-w-72",
|
|
105
|
+
labels: labelsProp
|
|
106
|
+
}
|
|
107
|
+
),
|
|
108
|
+
device !== effectiveZone && /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [
|
|
109
|
+
/* @__PURE__ */ jsx("span", { id: `${id}-device`, className: CARD_DESCRIPTION_CLASS, children: labels.deviceZone(zoneName(device, labels.regions)) }),
|
|
110
|
+
/* @__PURE__ */ jsx(
|
|
111
|
+
Button,
|
|
112
|
+
{
|
|
113
|
+
type: "button",
|
|
114
|
+
size: "sm",
|
|
115
|
+
variant: "link",
|
|
116
|
+
disabled,
|
|
117
|
+
commit: COMMIT_EXCEPT_BILLING,
|
|
118
|
+
"aria-describedby": `${id}-device`,
|
|
119
|
+
onClick: () => save({ timezone: device }),
|
|
120
|
+
children: labels.useDeviceZone
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
] })
|
|
124
|
+
] }),
|
|
125
|
+
/* @__PURE__ */ jsx(FailureLine, { children: failure })
|
|
126
|
+
] });
|
|
127
|
+
}
|
|
128
|
+
export {
|
|
129
|
+
QuietHoursSetting
|
|
130
|
+
};
|
|
131
|
+
//# sourceMappingURL=quiet-hours-setting.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\nimport { deviceTimeZone, TimeZoneSelect, zoneName } from \"./time-zone-select\";\n\n/** The zone the server applies while the account has none (§2.6): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /** The `GET`'s `timezone`, an IANA name, or `null` (the app's zone applies). */\n timezone: string | null;\n /**\n * Save one change — `PATCH /notifications/settings`: a time (`null` when cleared), with\n * the device's zone the first time one is entered while `timezone` is `null`; or\n * `{timezone}`. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone shown, and the one the server applies, while `timezone` is `null`.\n * Default `\"Europe/Zurich\"`, every app's (decision 6). */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\" | \"timezone\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped, not delayed; the inbox row and the mail go as\n * usual. keksdose's block, as a card of its own.\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set. The first time entered while the account has\n * none sends this device's zone with it, so \"22:00\" means the person's 22:00\n * (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows\n * `fallbackZone` — the zone the server then applies — never the device's (keksdose's\n * card showed the device's zone while its server applied UTC).\n * - **\"This device is in …\"** with \"Use it\" when the device's zone is not the one in\n * effect. Never written on its own: a zone is written by a person's pick (§2.6).\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n timezone,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}|${timezone}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const zoneShown = shown(\"timezone\", timezone);\n const effectiveZone = zoneShown ?? fallbackZone;\n const device = deviceTimeZone();\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n const patch: NotificationSettingsPatch = { [field]: next };\n if (next && zoneShown === null) patch.timezone = device;\n save(patch);\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <div className=\"space-y-2\">\n <TimeZoneSelect\n id={`${id}-zone`}\n value={effectiveZone}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onChange={(zone) => save({ timezone: zone })}\n className=\"max-w-72\"\n labels={labelsProp}\n />\n {device !== effectiveZone && (\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n <span id={`${id}-device`} className={CARD_DESCRIPTION_CLASS}>\n {labels.deviceZone(zoneName(device, labels.regions))}\n </span>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"link\"\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n aria-describedby={`${id}-device`}\n onClick={() => save({ timezone: device })}\n >\n {labels.useDeviceZone}\n </Button>\n </div>\n )}\n </div>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":";AAyHM,SACE,KADF;AAzHN,SAAS,OAAO,gBAAgB;AAIhC,SAAS,UAAU;AACnB,SAAS,cAAc;AACvB,SAAS,YAAY;AACrB,SAAS,iBAAiB;AAC1B,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,yBAAyB;AAClC,SAAS,QAAQ,kBAAkB;AACnC,SAAS,iBAAiB,aAAa,wBAAwB,uBAAuB;AAGtF,SAAS,gBAAgB,gBAAgB,gBAAgB;AAGzD,MAAM,wBAAwB;AA+CvB,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,WAAW;AAAA,EACX;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA2B;AACzB,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,UAAU,gBAA+B,GAAG,KAAK,IAAI,GAAG,IAAI,QAAQ,EAAE;AAC5E,QAAM,CAAC,SAAS,UAAU,IAAI,SAAoB,IAAI;AACtD,QAAM,UAAU,WAAW;AAC3B,QAAM,KAAK,MAAM;AAEjB,QAAM,QAAQ,CAAC,OAAmB,WAChC,QAAQ,IAAI,KAAK,IAAK,QAAQ,IAAI,KAAK,KAAK,OAAQ;AACtD,QAAM,aAAa,MAAM,qBAAqB,KAAK;AACnD,QAAM,WAAW,MAAM,mBAAmB,GAAG;AAC7C,QAAM,YAAY,MAAM,YAAY,QAAQ;AAC5C,QAAM,gBAAgB,aAAa;AACnC,QAAM,SAAS,eAAe;AAE9B,QAAM,OAAO,CAAC,UAAqC;AACjD,UAAM,SAAS,OAAO,KAAK,KAAK;AAChC,eAAW,IAAI;AACf,eAAW,SAAS,OAAQ,SAAQ,IAAI,OAAO,MAAM,KAAK,KAAK,IAAI;AACnE,WAAO,MAAM,SAAS,KAAK,CAAC,EAAE;AAAA,MAC5B,MAAM;AACJ,YAAI,QAAQ,QAAS,YAAW,SAAS,OAAQ,SAAQ,OAAO,OAAO,IAAI;AAAA,MAC7E;AAAA,MACA,CAAC,UAAmB;AAClB,YAAI,CAAC,QAAQ,QAAS;AACtB,mBAAW,SAAS,OAAQ,SAAQ,OAAO,OAAO,KAAK;AACvD,mBAAW,gBAAgB,gBAAgB,KAAK,GAAG,OAAO,MAAM,CAAC;AAAA,MACnE;AAAA,IACF;AAAA,EACF;AAEA,QAAM,WAAW,CAAC,OAAgD,UAAkB;AAClF,UAAM,OAAO,SAAS;AACtB,QAAI,SAAS,MAAM,OAAO,UAAU,sBAAsB,QAAQ,GAAG,EAAG;AACxE,UAAM,QAAmC,EAAE,CAAC,KAAK,GAAG,KAAK;AACzD,QAAI,QAAQ,cAAc,KAAM,OAAM,WAAW;AACjD,SAAK,KAAK;AAAA,EACZ;AAEA,QAAM,SAAS,QAAQ,cAAc,QAAQ;AAC7C,QAAM,UAAU,QAAQ,UAAU,MAAM,QAAQ,QAAQ;AAExD,SACE,qBAAC,QAAM,GAAG,MAAM,WAAW,GAAG,iBAAiB,SAAS,GACtD;AAAA,yBAAC,SACC;AAAA,0BAAC,qBAAmB,iBAAO,YAAW;AAAA,MACtC,oBAAC,SAAI,WAAW,wBAAyB,iBAAO,iBAAiB,KAAK,GAAE;AAAA,OAC1E;AAAA,IACA,qBAAC,SAAI,WAAU,kCACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO,OAAO;AAAA,UACd,OAAO,cAAc;AAAA,UACrB;AAAA,UACA,QAAQ;AAAA,UACR,eAAe,CAAC,UAAU,SAAS,qBAAqB,KAAK;AAAA,UAC7D,WAAU;AAAA;AAAA,MACZ;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO,OAAO;AAAA,UACd,OAAO,YAAY;AAAA,UACnB;AAAA,UACA,QAAQ;AAAA,UACR,eAAe,CAAC,UAAU,SAAS,mBAAmB,KAAK;AAAA,UAC3D,WAAU;AAAA;AAAA,MACZ;AAAA,OACF;AAAA,IACC,WACC,oBAAC,OAAE,WAAU,wBAAuB,yBAAsB,IACvD,iBAAO,iBACV;AAAA,IAED,UACC,qBAAC,SAAI,WAAU,aACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO;AAAA,UACP;AAAA,UACA,QAAQ;AAAA,UACR,UAAU,CAAC,SAAS,KAAK,EAAE,UAAU,KAAK,CAAC;AAAA,UAC3C,WAAU;AAAA,UACV,QAAQ;AAAA;AAAA,MACV;AAAA,MACC,WAAW,iBACV,qBAAC,SAAI,WAAU,+CACb;AAAA,4BAAC,UAAK,IAAI,GAAG,EAAE,WAAW,WAAW,wBAClC,iBAAO,WAAW,SAAS,QAAQ,OAAO,OAAO,CAAC,GACrD;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,SAAQ;AAAA,YACR;AAAA,YACA,QAAQ;AAAA,YACR,oBAAkB,GAAG,EAAE;AAAA,YACvB,SAAS,MAAM,KAAK,EAAE,UAAU,OAAO,CAAC;AAAA,YAEvC,iBAAO;AAAA;AAAA,QACV;AAAA,SACF;AAAA,OAEJ;AAAA,IAEF,oBAAC,eAAa,mBAAQ;AAAA,KACxB;AAEJ;","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LabelOverride } from "../i18n/kit-labels.js";
|
|
3
|
+
import type { ComboOption } from "../components/combobox-core.js";
|
|
4
|
+
import type { CommitScope } from "../components/write-lock.js";
|
|
5
|
+
import type { NotificationRegionLabels, NotificationsLabels } from "./notifications-labels.js";
|
|
6
|
+
/**
|
|
7
|
+
* This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
|
|
8
|
+
* the zone quiet hours are entered in the first time (§2.6), and the one the card offers
|
|
9
|
+
* when it differs from the account's.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deviceTimeZone(): string;
|
|
12
|
+
/**
|
|
13
|
+
* The zone picker's rows (keksdose `notifications-card.tsx:131-146`): every zone `Intl`
|
|
14
|
+
* lists, as its city ("Argentina / Buenos Aires") under its region, the region named by
|
|
15
|
+
* `regionLabel` — which answers `undefined` for a region it does not know, and the region
|
|
16
|
+
* keeps its IANA name. A zone with no region ("UTC") comes first, ahead of every heading,
|
|
17
|
+
* rather than trailing the last group as if it were Pacific. `current` is kept even when
|
|
18
|
+
* the runtime does not list it (an old alias such as "Europe/Kiev"): a picker without its
|
|
19
|
+
* own value would show an empty field.
|
|
20
|
+
*/
|
|
21
|
+
export declare function timeZoneOptions(current: string, regionLabel: (region: string) => string | undefined): ComboOption<string>[];
|
|
22
|
+
/** The region's name in `regions`, or `undefined` for one the labels do not know.
|
|
23
|
+
* @internal Not part of the barrel. */
|
|
24
|
+
export declare function regionName(regions: NotificationRegionLabels, region: string): string | undefined;
|
|
25
|
+
/** A zone as the picker shows it, for a sentence: "Europe / Zurich", the region in the
|
|
26
|
+
* kit's words. @internal Not part of the barrel. */
|
|
27
|
+
export declare function zoneName(zone: string, regions: NotificationRegionLabels): string;
|
|
28
|
+
export interface TimeZoneSelectProps {
|
|
29
|
+
/** The IANA zone. Not clearable: a zone can be changed, never emptied. */
|
|
30
|
+
value: string;
|
|
31
|
+
/** The zone picked. Never called for the value it already has. */
|
|
32
|
+
onChange: (zone: string) => void;
|
|
33
|
+
/** Default `labels.timeZone`. */
|
|
34
|
+
label?: ReactNode;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** The input's id. */
|
|
37
|
+
id?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
/** The picker SAVES on change (quiet hours do): locked under a write lock in scope. */
|
|
40
|
+
commit?: CommitScope;
|
|
41
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* An IANA time zone, searchable (docs/notifications-harmonization.md §4.2, keksdose's
|
|
45
|
+
* quiet-hours picker): type "Zur" for Europe/Zurich, and on a phone the kit's sheet with
|
|
46
|
+
* a search box instead of a 400-row wheel. Rows are the city under its region, the region
|
|
47
|
+
* in the kit's words (`labels.regions`); a zone with no region first; a stored zone the
|
|
48
|
+
* runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.
|
|
49
|
+
*/
|
|
50
|
+
export declare function TimeZoneSelect({ value, onChange, label, disabled, id, className, commit, labels: labelsProp, }: TimeZoneSelectProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { InlineEntityCombobox } from "../components/inline-entity-combobox.js";
|
|
5
|
+
import { useNotificationsLabels } from "./notification-parts.js";
|
|
6
|
+
function deviceTimeZone() {
|
|
7
|
+
try {
|
|
8
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
9
|
+
} catch {
|
|
10
|
+
return "UTC";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function runtimeZones() {
|
|
14
|
+
const intl = Intl;
|
|
15
|
+
return typeof intl.supportedValuesOf === "function" ? intl.supportedValuesOf("timeZone") : [];
|
|
16
|
+
}
|
|
17
|
+
function cityOf(rest) {
|
|
18
|
+
return rest.replace(/_/g, " ").replace(/\//g, " / ");
|
|
19
|
+
}
|
|
20
|
+
function timeZoneOptions(current, regionLabel) {
|
|
21
|
+
const zones = runtimeZones();
|
|
22
|
+
const all = !current || zones.includes(current) ? zones : [...zones, current].sort();
|
|
23
|
+
const rows = all.map((zone) => {
|
|
24
|
+
const slash = zone.indexOf("/");
|
|
25
|
+
if (slash < 0) return { value: zone, label: zone };
|
|
26
|
+
const region = zone.slice(0, slash);
|
|
27
|
+
return { value: zone, label: cityOf(zone.slice(slash + 1)), group: regionLabel(region) ?? region };
|
|
28
|
+
});
|
|
29
|
+
return [...rows.filter((row) => !row.group), ...rows.filter((row) => row.group)];
|
|
30
|
+
}
|
|
31
|
+
function regionName(regions, region) {
|
|
32
|
+
return Object.prototype.hasOwnProperty.call(regions, region) ? regions[region] : void 0;
|
|
33
|
+
}
|
|
34
|
+
function zoneName(zone, regions) {
|
|
35
|
+
const slash = zone.indexOf("/");
|
|
36
|
+
if (slash < 0) return zone;
|
|
37
|
+
const region = zone.slice(0, slash);
|
|
38
|
+
return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;
|
|
39
|
+
}
|
|
40
|
+
function TimeZoneSelect({
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
label,
|
|
44
|
+
disabled,
|
|
45
|
+
id,
|
|
46
|
+
className,
|
|
47
|
+
commit,
|
|
48
|
+
labels: labelsProp
|
|
49
|
+
}) {
|
|
50
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
51
|
+
const { regions } = labels;
|
|
52
|
+
const options = useMemo(() => timeZoneOptions(value, (region) => regionName(regions, region)), [value, regions]);
|
|
53
|
+
return /* @__PURE__ */ jsx(
|
|
54
|
+
InlineEntityCombobox,
|
|
55
|
+
{
|
|
56
|
+
id,
|
|
57
|
+
label: label ?? labels.timeZone,
|
|
58
|
+
value,
|
|
59
|
+
options,
|
|
60
|
+
disabled,
|
|
61
|
+
commit,
|
|
62
|
+
onChange: (zone) => {
|
|
63
|
+
if (zone && zone !== value) onChange(zone);
|
|
64
|
+
},
|
|
65
|
+
className
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
TimeZoneSelect,
|
|
71
|
+
deviceTimeZone,
|
|
72
|
+
regionName,
|
|
73
|
+
timeZoneOptions,
|
|
74
|
+
zoneName
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=time-zone-select.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/time-zone-select.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport type { ComboOption } from \"../components/combobox-core\";\nimport { InlineEntityCombobox } from \"../components/inline-entity-combobox\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\n\n/**\n * This device's IANA zone (\"Europe/Zurich\"), or `\"UTC\"` where the runtime names none —\n * the zone quiet hours are entered in the first time (§2.6), and the one the card offers\n * when it differs from the account's.\n */\nexport function deviceTimeZone(): string {\n try {\n return Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\";\n } catch {\n return \"UTC\";\n }\n}\n\n/** Every zone the runtime lists; `[]` where `Intl.supportedValuesOf` is missing. */\nfunction runtimeZones(): string[] {\n const intl = Intl as typeof Intl & { supportedValuesOf?: (key: \"timeZone\") => string[] };\n return typeof intl.supportedValuesOf === \"function\" ? intl.supportedValuesOf(\"timeZone\") : [];\n}\n\n/** \"Argentina/Buenos_Aires\" → \"Argentina / Buenos Aires\": the zone's city, as a row. */\nfunction cityOf(rest: string): string {\n return rest.replace(/_/g, \" \").replace(/\\//g, \" / \");\n}\n\n/**\n * The zone picker's rows (keksdose `notifications-card.tsx:131-146`): every zone `Intl`\n * lists, as its city (\"Argentina / Buenos Aires\") under its region, the region named by\n * `regionLabel` — which answers `undefined` for a region it does not know, and the region\n * keeps its IANA name. A zone with no region (\"UTC\") comes first, ahead of every heading,\n * rather than trailing the last group as if it were Pacific. `current` is kept even when\n * the runtime does not list it (an old alias such as \"Europe/Kiev\"): a picker without its\n * own value would show an empty field.\n */\nexport function timeZoneOptions(\n current: string,\n regionLabel: (region: string) => string | undefined,\n): ComboOption<string>[] {\n const zones = runtimeZones();\n const all = !current || zones.includes(current) ? zones : [...zones, current].sort();\n const rows = all.map((zone): ComboOption<string> => {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return { value: zone, label: zone };\n const region = zone.slice(0, slash);\n return { value: zone, label: cityOf(zone.slice(slash + 1)), group: regionLabel(region) ?? region };\n });\n return [...rows.filter((row) => !row.group), ...rows.filter((row) => row.group)];\n}\n\n/** The region's name in `regions`, or `undefined` for one the labels do not know.\n * @internal Not part of the barrel. */\nexport function regionName(regions: NotificationRegionLabels, region: string): string | undefined {\n return Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : undefined;\n}\n\n/** A zone as the picker shows it, for a sentence: \"Europe / Zurich\", the region in the\n * kit's words. @internal Not part of the barrel. */\nexport function zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;\n}\n\nexport interface TimeZoneSelectProps {\n /** The IANA zone. Not clearable: a zone can be changed, never emptied. */\n value: string;\n /** The zone picked. Never called for the value it already has. */\n onChange: (zone: string) => void;\n /** Default `labels.timeZone`. */\n label?: ReactNode;\n disabled?: boolean;\n /** The input's id. */\n id?: string;\n className?: string;\n /** The picker SAVES on change (quiet hours do): locked under a write lock in scope. */\n commit?: CommitScope;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/**\n * An IANA time zone, searchable (docs/notifications-harmonization.md §4.2, keksdose's\n * quiet-hours picker): type \"Zur\" for Europe/Zurich, and on a phone the kit's sheet with\n * a search box instead of a 400-row wheel. Rows are the city under its region, the region\n * in the kit's words (`labels.regions`); a zone with no region first; a stored zone the\n * runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.\n */\nexport function TimeZoneSelect({\n value,\n onChange,\n label,\n disabled,\n id,\n className,\n commit,\n labels: labelsProp,\n}: TimeZoneSelectProps) {\n const labels = useNotificationsLabels(labelsProp);\n const { regions } = labels;\n const options = useMemo(() => timeZoneOptions(value, (region) => regionName(regions, region)), [value, regions]);\n return (\n <InlineEntityCombobox<string>\n id={id}\n label={label ?? labels.timeZone}\n value={value}\n options={options}\n disabled={disabled}\n commit={commit}\n onChange={(zone) => {\n if (zone && zone !== value) onChange(zone);\n }}\n className={className}\n />\n );\n}\n"],"mappings":";AAgHI;AAhHJ,SAAS,eAAe;AAKxB,SAAS,4BAA4B;AAErC,SAAS,8BAA8B;AAQhC,SAAS,iBAAyB;AACvC,MAAI;AACF,WAAO,KAAK,eAAe,EAAE,gBAAgB,EAAE,YAAY;AAAA,EAC7D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,eAAyB;AAChC,QAAM,OAAO;AACb,SAAO,OAAO,KAAK,sBAAsB,aAAa,KAAK,kBAAkB,UAAU,IAAI,CAAC;AAC9F;AAGA,SAAS,OAAO,MAAsB;AACpC,SAAO,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,OAAO,KAAK;AACrD;AAWO,SAAS,gBACd,SACA,aACuB;AACvB,QAAM,QAAQ,aAAa;AAC3B,QAAM,MAAM,CAAC,WAAW,MAAM,SAAS,OAAO,IAAI,QAAQ,CAAC,GAAG,OAAO,OAAO,EAAE,KAAK;AACnF,QAAM,OAAO,IAAI,IAAI,CAAC,SAA8B;AAClD,UAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,QAAI,QAAQ,EAAG,QAAO,EAAE,OAAO,MAAM,OAAO,KAAK;AACjD,UAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,WAAO,EAAE,OAAO,MAAM,OAAO,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,GAAG,OAAO,YAAY,MAAM,KAAK,OAAO;AAAA,EACnG,CAAC;AACD,SAAO,CAAC,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,IAAI,KAAK,GAAG,GAAG,KAAK,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;AACjF;AAIO,SAAS,WAAW,SAAmC,QAAoC;AAChG,SAAO,OAAO,UAAU,eAAe,KAAK,SAAS,MAAM,IACvD,QAAQ,MAAwC,IAChD;AACN;AAIO,SAAS,SAAS,MAAc,SAA2C;AAChF,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,QAAQ,EAAG,QAAO;AACtB,QAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,SAAO,GAAG,WAAW,SAAS,MAAM,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,CAAC;AACpF;AAyBO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAAwB;AACtB,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,EAAE,QAAQ,IAAI;AACpB,QAAM,UAAU,QAAQ,MAAM,gBAAgB,OAAO,CAAC,WAAW,WAAW,SAAS,MAAM,CAAC,GAAG,CAAC,OAAO,OAAO,CAAC;AAC/G,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,SAAS,OAAO;AAAA,MACvB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,UAAU,CAAC,SAAS;AAClB,YAAI,QAAQ,SAAS,MAAO,UAAS,IAAI;AAAA,MAC3C;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;","names":[]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
2
|
+
import type { LabelOverride } from "../i18n/kit-labels.js";
|
|
3
|
+
import type { AuthHeadingLevel } from "../auth/status-parts.js";
|
|
4
|
+
import type { NotificationsLabels } from "./notifications-labels.js";
|
|
5
|
+
/**
|
|
6
|
+
* The notification type an unsubscribe token names, read from its `<data>` half —
|
|
7
|
+
* `v1.<base64url("<user_id>:<type id>")>.<mac>` (docs/notifications-harmonization.md
|
|
8
|
+
* §3.6) — or `null` for a token that does not parse. Only the claim is read: the MAC is
|
|
9
|
+
* the server's to check, so a forged token still names a type here and the server
|
|
10
|
+
* refuses it. For the page's words ("You get e-mails about: Overdue invoices").
|
|
11
|
+
*/
|
|
12
|
+
export declare function unsubscribeTokenType(token: string): string | null;
|
|
13
|
+
export interface UnsubscribePanelProps extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
|
|
14
|
+
/** The `token` from the page's query (`/unsubscribe?token=…`). */
|
|
15
|
+
token: string;
|
|
16
|
+
/** The app's name, for "Stop all notification e-mails from {app}". */
|
|
17
|
+
appName: string;
|
|
18
|
+
/** The type's name in the app's words, from {@link unsubscribeTokenType}. Without it
|
|
19
|
+
* the page says only that notification mail comes. */
|
|
20
|
+
typeLabel?: string;
|
|
21
|
+
/** `POST /notifications/unsubscribe/{token}`: that type's e-mail off. */
|
|
22
|
+
onStop: (token: string) => Promise<unknown>;
|
|
23
|
+
/** `POST /notifications/unsubscribe/{token}/all`: every e-mail switch off. */
|
|
24
|
+
onStopAll: (token: string) => Promise<unknown>;
|
|
25
|
+
/** Settings → Notifications, through the provider's link component. */
|
|
26
|
+
settingsHref: string;
|
|
27
|
+
/** Whether a rejection means the link is no good. Default: a 400 with
|
|
28
|
+
* `code: "unsubscribe_invalid"`. Anything else is "try again". */
|
|
29
|
+
isInvalid?: (error: unknown) => boolean;
|
|
30
|
+
/** The heading. Default `labels.unsubscribeTitle`; `null` draws none, for a page that
|
|
31
|
+
* puts it in `AuthLayout`'s `title`. */
|
|
32
|
+
title?: ReactNode;
|
|
33
|
+
/** The heading's level. Default `h2`, under the page's `h1`. */
|
|
34
|
+
headingAs?: AuthHeadingLevel;
|
|
35
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The body of an app's signed-out `/unsubscribe` page (docs/notifications-harmonization.md
|
|
39
|
+
* §3.6, §4.2) — the page a notification mail's footer links to. It works signed in or
|
|
40
|
+
* out (auth §7), and is not a card.
|
|
41
|
+
*
|
|
42
|
+
* **Nothing happens on a GET**: link scanners fetch every URL, so the page asks first —
|
|
43
|
+
* "Stop these e-mails", or "Stop all notification e-mails from {app}" — and only a press
|
|
44
|
+
* POSTs. A mail client's one-click POST goes to the server's route directly, without
|
|
45
|
+
* this page. Account mail is not a notification and keeps coming; the page says so, and
|
|
46
|
+
* then how to turn the mail back on (Settings → Notifications).
|
|
47
|
+
*
|
|
48
|
+
* A token that does not parse is "this link doesn't work" at once, with no request; the
|
|
49
|
+
* server's refusal (`unsubscribe_invalid`) says the same. Any other failure is "try
|
|
50
|
+
* again", the buttons still there. A valid token always answers 200 — also when the
|
|
51
|
+
* switch was already off or the account is gone — so the done state reveals nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function UnsubscribePanel({ token, appName, typeLabel, onStop, onStopAll, settingsHref, isInvalid, title, headingAs: Heading, labels: labelsProp, className, ...rest }: UnsubscribePanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import { CheckCircle2, Link2Off } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { errorBodyWithCode, httpStatusOf } from "../auth/auth-errors.js";
|
|
7
|
+
import { INTRO_CLASS, OUTCOME_CLASS, STATUS_HEADING_CLASS, useFocusWhen } from "../auth/status-parts.js";
|
|
8
|
+
import { Button } from "../components/button.js";
|
|
9
|
+
import { EmptyState } from "../components/empty-state.js";
|
|
10
|
+
import { useMounted } from "../account/account-parts.js";
|
|
11
|
+
import { FailureLine, useNotificationsLabels } from "./notification-parts.js";
|
|
12
|
+
const TYPE_ID = /^[a-z][a-z0-9_]{1,39}$/;
|
|
13
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
function unsubscribeTokenType(token) {
|
|
15
|
+
const parts = token.split(".");
|
|
16
|
+
if (parts.length !== 3) return null;
|
|
17
|
+
const [version, data, mac] = parts;
|
|
18
|
+
if (version !== "v1" || !BASE64URL.test(data) || !BASE64URL.test(mac) || data.length % 4 === 1) return null;
|
|
19
|
+
let claim;
|
|
20
|
+
try {
|
|
21
|
+
claim = atob(data.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - data.length % 4) % 4));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const colon = claim.indexOf(":");
|
|
26
|
+
if (colon < 1) return null;
|
|
27
|
+
const type = claim.slice(colon + 1);
|
|
28
|
+
return TYPE_ID.test(type) ? type : null;
|
|
29
|
+
}
|
|
30
|
+
function defaultIsInvalid(error) {
|
|
31
|
+
const coded = errorBodyWithCode(error, (code) => code === "unsubscribe_invalid") !== void 0;
|
|
32
|
+
const status = httpStatusOf(error);
|
|
33
|
+
return coded && (status === void 0 || status === 400);
|
|
34
|
+
}
|
|
35
|
+
function UnsubscribePanel({
|
|
36
|
+
token,
|
|
37
|
+
appName,
|
|
38
|
+
typeLabel,
|
|
39
|
+
onStop,
|
|
40
|
+
onStopAll,
|
|
41
|
+
settingsHref,
|
|
42
|
+
isInvalid = defaultIsInvalid,
|
|
43
|
+
title,
|
|
44
|
+
headingAs: Heading = "h2",
|
|
45
|
+
labels: labelsProp,
|
|
46
|
+
className,
|
|
47
|
+
...rest
|
|
48
|
+
}) {
|
|
49
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
50
|
+
const parses = unsubscribeTokenType(token) !== null;
|
|
51
|
+
const [phase, setPhase] = useState("ready");
|
|
52
|
+
const mounted = useMounted();
|
|
53
|
+
const outcomeRef = useRef(null);
|
|
54
|
+
const shown = parses ? phase : "invalid";
|
|
55
|
+
const done = shown === "stopped-these" || shown === "stopped-all";
|
|
56
|
+
useFocusWhen(done, outcomeRef);
|
|
57
|
+
const stop = (which) => {
|
|
58
|
+
if (shown !== "ready" && shown !== "failed") return;
|
|
59
|
+
setPhase(which);
|
|
60
|
+
const call = which === "these" ? onStop : onStopAll;
|
|
61
|
+
call(token).then(
|
|
62
|
+
() => {
|
|
63
|
+
if (mounted.current) setPhase(which === "these" ? "stopped-these" : "stopped-all");
|
|
64
|
+
},
|
|
65
|
+
(error) => {
|
|
66
|
+
if (mounted.current) setPhase(isInvalid(error) ? "invalid" : "failed");
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
const settingsLink = /* @__PURE__ */ jsx(Button, { href: settingsHref, variant: "secondary", children: labels.openSettings });
|
|
71
|
+
let body;
|
|
72
|
+
if (shown === "invalid") {
|
|
73
|
+
body = /* @__PURE__ */ jsx(
|
|
74
|
+
EmptyState,
|
|
75
|
+
{
|
|
76
|
+
tone: "danger",
|
|
77
|
+
icon: /* @__PURE__ */ jsx(Link2Off, {}),
|
|
78
|
+
title: labels.invalidLink,
|
|
79
|
+
action: settingsLink,
|
|
80
|
+
className: OUTCOME_CLASS
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
} else if (done) {
|
|
84
|
+
body = /* @__PURE__ */ jsx("div", { ref: outcomeRef, tabIndex: -1, className: "outline-none", children: /* @__PURE__ */ jsx(
|
|
85
|
+
EmptyState,
|
|
86
|
+
{
|
|
87
|
+
tone: "success",
|
|
88
|
+
icon: /* @__PURE__ */ jsx(CheckCircle2, {}),
|
|
89
|
+
title: shown === "stopped-these" ? labels.stoppedThese : labels.stoppedAll(appName),
|
|
90
|
+
hint: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
91
|
+
labels.accountMail,
|
|
92
|
+
" ",
|
|
93
|
+
labels.turnBackOn
|
|
94
|
+
] }),
|
|
95
|
+
action: settingsLink,
|
|
96
|
+
className: OUTCOME_CLASS
|
|
97
|
+
}
|
|
98
|
+
) });
|
|
99
|
+
} else {
|
|
100
|
+
const working = shown === "these" || shown === "all";
|
|
101
|
+
body = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
102
|
+
/* @__PURE__ */ jsx("p", { className: INTRO_CLASS, children: typeLabel ? labels.unsubscribeAbout(typeLabel) : labels.unsubscribeGeneric }),
|
|
103
|
+
/* @__PURE__ */ jsx("p", { className: INTRO_CLASS, children: labels.accountMail }),
|
|
104
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 sm:flex-row sm:flex-wrap", children: [
|
|
105
|
+
/* @__PURE__ */ jsx(
|
|
106
|
+
Button,
|
|
107
|
+
{
|
|
108
|
+
type: "button",
|
|
109
|
+
pending: shown === "these",
|
|
110
|
+
disabled: working && shown !== "these",
|
|
111
|
+
onClick: () => stop("these"),
|
|
112
|
+
children: labels.stopThese
|
|
113
|
+
}
|
|
114
|
+
),
|
|
115
|
+
/* @__PURE__ */ jsx(
|
|
116
|
+
Button,
|
|
117
|
+
{
|
|
118
|
+
type: "button",
|
|
119
|
+
variant: "secondary",
|
|
120
|
+
pending: shown === "all",
|
|
121
|
+
disabled: working && shown !== "all",
|
|
122
|
+
onClick: () => stop("all"),
|
|
123
|
+
children: labels.stopAll(appName)
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
] }),
|
|
127
|
+
shown === "failed" && /* @__PURE__ */ jsx(FailureLine, { children: labels.failed })
|
|
128
|
+
] });
|
|
129
|
+
}
|
|
130
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, "data-state": shown, className: cn("space-y-3", className), children: [
|
|
131
|
+
title === null ? null : /* @__PURE__ */ jsx(Heading, { className: STATUS_HEADING_CLASS, children: title ?? labels.unsubscribeTitle }),
|
|
132
|
+
body
|
|
133
|
+
] });
|
|
134
|
+
}
|
|
135
|
+
export {
|
|
136
|
+
UnsubscribePanel,
|
|
137
|
+
unsubscribeTokenType
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=unsubscribe-panel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/unsubscribe-panel.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, Link2Off } from \"lucide-react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { errorBodyWithCode, httpStatusOf } from \"../auth/auth-errors\";\nimport { INTRO_CLASS, OUTCOME_CLASS, STATUS_HEADING_CLASS, useFocusWhen } from \"../auth/status-parts\";\nimport type { AuthHeadingLevel } from \"../auth/status-parts\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { useMounted } from \"../account/account-parts\";\nimport { FailureLine, useNotificationsLabels } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\n\n/** A type id, as the server's registry allows it (§2.1). */\nconst TYPE_ID = /^[a-z][a-z0-9_]{1,39}$/;\n/** base64url, unpadded, as the token's `<data>` is written (§3.6). */\nconst BASE64URL = /^[A-Za-z0-9_-]+$/;\n\n/**\n * The notification type an unsubscribe token names, read from its `<data>` half —\n * `v1.<base64url(\"<user_id>:<type id>\")>.<mac>` (docs/notifications-harmonization.md\n * §3.6) — or `null` for a token that does not parse. Only the claim is read: the MAC is\n * the server's to check, so a forged token still names a type here and the server\n * refuses it. For the page's words (\"You get e-mails about: Overdue invoices\").\n */\nexport function unsubscribeTokenType(token: string): string | null {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const [version, data, mac] = parts;\n if (version !== \"v1\" || !BASE64URL.test(data) || !BASE64URL.test(mac) || data.length % 4 === 1) return null;\n let claim: string;\n try {\n claim = atob(data.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (data.length % 4)) % 4));\n } catch {\n return null;\n }\n const colon = claim.indexOf(\":\");\n if (colon < 1) return null;\n const type = claim.slice(colon + 1);\n return TYPE_ID.test(type) ? type : null;\n}\n\n/** server-kit's `UnsubscribeTokenError`: 400 `{code: \"unsubscribe_invalid\"}`. A body\n * thrown as it came, with no status, counts on its code. */\nfunction defaultIsInvalid(error: unknown): boolean {\n const coded = errorBodyWithCode(error, (code) => code === \"unsubscribe_invalid\") !== undefined;\n const status = httpStatusOf(error);\n return coded && (status === undefined || status === 400);\n}\n\nexport interface UnsubscribePanelProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The `token` from the page's query (`/unsubscribe?token=…`). */\n token: string;\n /** The app's name, for \"Stop all notification e-mails from {app}\". */\n appName: string;\n /** The type's name in the app's words, from {@link unsubscribeTokenType}. Without it\n * the page says only that notification mail comes. */\n typeLabel?: string;\n /** `POST /notifications/unsubscribe/{token}`: that type's e-mail off. */\n onStop: (token: string) => Promise<unknown>;\n /** `POST /notifications/unsubscribe/{token}/all`: every e-mail switch off. */\n onStopAll: (token: string) => Promise<unknown>;\n /** Settings → Notifications, through the provider's link component. */\n settingsHref: string;\n /** Whether a rejection means the link is no good. Default: a 400 with\n * `code: \"unsubscribe_invalid\"`. Anything else is \"try again\". */\n isInvalid?: (error: unknown) => boolean;\n /** The heading. Default `labels.unsubscribeTitle`; `null` draws none, for a page that\n * puts it in `AuthLayout`'s `title`. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype Phase = \"ready\" | \"these\" | \"all\" | \"stopped-these\" | \"stopped-all\" | \"invalid\" | \"failed\";\n\n/**\n * The body of an app's signed-out `/unsubscribe` page (docs/notifications-harmonization.md\n * §3.6, §4.2) — the page a notification mail's footer links to. It works signed in or\n * out (auth §7), and is not a card.\n *\n * **Nothing happens on a GET**: link scanners fetch every URL, so the page asks first —\n * \"Stop these e-mails\", or \"Stop all notification e-mails from {app}\" — and only a press\n * POSTs. A mail client's one-click POST goes to the server's route directly, without\n * this page. Account mail is not a notification and keeps coming; the page says so, and\n * then how to turn the mail back on (Settings → Notifications).\n *\n * A token that does not parse is \"this link doesn't work\" at once, with no request; the\n * server's refusal (`unsubscribe_invalid`) says the same. Any other failure is \"try\n * again\", the buttons still there. A valid token always answers 200 — also when the\n * switch was already off or the account is gone — so the done state reveals nothing.\n */\nexport function UnsubscribePanel({\n token,\n appName,\n typeLabel,\n onStop,\n onStopAll,\n settingsHref,\n isInvalid = defaultIsInvalid,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: UnsubscribePanelProps) {\n const labels = useNotificationsLabels(labelsProp);\n const parses = unsubscribeTokenType(token) !== null;\n const [phase, setPhase] = useState<Phase>(\"ready\");\n const mounted = useMounted();\n const outcomeRef = useRef<HTMLDivElement>(null);\n const shown: Phase = parses ? phase : \"invalid\";\n const done = shown === \"stopped-these\" || shown === \"stopped-all\";\n useFocusWhen(done, outcomeRef);\n\n const stop = (which: \"these\" | \"all\") => {\n if (shown !== \"ready\" && shown !== \"failed\") return;\n setPhase(which);\n const call = which === \"these\" ? onStop : onStopAll;\n call(token).then(\n () => {\n if (mounted.current) setPhase(which === \"these\" ? \"stopped-these\" : \"stopped-all\");\n },\n (error: unknown) => {\n if (mounted.current) setPhase(isInvalid(error) ? \"invalid\" : \"failed\");\n },\n );\n };\n\n const settingsLink = (\n <Button href={settingsHref} variant=\"secondary\">\n {labels.openSettings}\n </Button>\n );\n\n let body: ReactNode;\n if (shown === \"invalid\") {\n body = (\n <EmptyState\n tone=\"danger\"\n icon={<Link2Off />}\n title={labels.invalidLink}\n action={settingsLink}\n className={OUTCOME_CLASS}\n />\n );\n } else if (done) {\n body = (\n <div ref={outcomeRef} tabIndex={-1} className=\"outline-none\">\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={shown === \"stopped-these\" ? labels.stoppedThese : labels.stoppedAll(appName)}\n hint={\n <>\n {labels.accountMail} {labels.turnBackOn}\n </>\n }\n action={settingsLink}\n className={OUTCOME_CLASS}\n />\n </div>\n );\n } else {\n const working = shown === \"these\" || shown === \"all\";\n body = (\n <>\n <p className={INTRO_CLASS}>{typeLabel ? labels.unsubscribeAbout(typeLabel) : labels.unsubscribeGeneric}</p>\n <p className={INTRO_CLASS}>{labels.accountMail}</p>\n <div className=\"flex flex-col gap-2 sm:flex-row sm:flex-wrap\">\n <Button\n type=\"button\"\n pending={shown === \"these\"}\n disabled={working && shown !== \"these\"}\n onClick={() => stop(\"these\")}\n >\n {labels.stopThese}\n </Button>\n <Button\n type=\"button\"\n variant=\"secondary\"\n pending={shown === \"all\"}\n disabled={working && shown !== \"all\"}\n onClick={() => stop(\"all\")}\n >\n {labels.stopAll(appName)}\n </Button>\n </div>\n {shown === \"failed\" && <FailureLine>{labels.failed}</FailureLine>}\n </>\n );\n }\n\n return (\n <div {...rest} data-state={shown} className={cn(\"space-y-3\", className)}>\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.unsubscribeTitle}</Heading>}\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This browser's Web Push subscription for the signed-in account
|
|
3
|
+
* (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's
|
|
4
|
+
* `shared/push/use-push.ts`, lifted. The service worker with its `push` and
|
|
5
|
+
* `notificationclick` handlers stays the app's; this hook owns the browser's side:
|
|
6
|
+
* permission, subscribe with the server's VAPID key, register the subscription, and
|
|
7
|
+
* unsubscribe. The kit makes no request of its own: the app hands it one function per
|
|
8
|
+
* endpoint.
|
|
9
|
+
*/
|
|
10
|
+
/** The browser's notification permission, or `"unsupported"` where it has no push. */
|
|
11
|
+
export type PushPermission = NotificationPermission | "unsupported";
|
|
12
|
+
/** Why turning push on or off could not finish — a {@link PushDeviceError}'s `code`. */
|
|
13
|
+
export type PushDeviceErrorCode = "no_service_worker" | "push_timeout";
|
|
14
|
+
/**
|
|
15
|
+
* The two failures the card has words of its own for: no service worker registered (or
|
|
16
|
+
* one not ready in 10 s) — "reload and try again" — and a browser push service that did
|
|
17
|
+
* not answer in 15 s — "check your connection". Anything else is the app's request
|
|
18
|
+
* failing, rejected as it came.
|
|
19
|
+
*/
|
|
20
|
+
export declare class PushDeviceError extends Error {
|
|
21
|
+
readonly code: PushDeviceErrorCode;
|
|
22
|
+
constructor(code: PushDeviceErrorCode);
|
|
23
|
+
}
|
|
24
|
+
/** What `register` sends: `POST /notifications/devices` (§3.5). */
|
|
25
|
+
export interface PushSubscriptionInput {
|
|
26
|
+
endpoint: string;
|
|
27
|
+
keys: {
|
|
28
|
+
p256dh: string;
|
|
29
|
+
auth: string;
|
|
30
|
+
};
|
|
31
|
+
user_agent: string | null;
|
|
32
|
+
}
|
|
33
|
+
export interface UsePushDeviceOptions {
|
|
34
|
+
/** `GET /notifications/push-key` → its `key`; `""` when push is off on the server. */
|
|
35
|
+
pushKey: () => Promise<string>;
|
|
36
|
+
/** `POST /notifications/devices`. A 409 (another account's endpoint) is recovered
|
|
37
|
+
* once: the hook unsubscribes and registers a fresh endpoint. */
|
|
38
|
+
register: (subscription: PushSubscriptionInput) => Promise<unknown>;
|
|
39
|
+
/** `POST /notifications/devices/unregister {endpoint}` — the endpoint never goes in a
|
|
40
|
+
* URL. */
|
|
41
|
+
unregister: (endpoint: string) => Promise<unknown>;
|
|
42
|
+
/** `GET /notifications/devices`: the account's own list, which says whether THIS
|
|
43
|
+
* account holds this browser's subscription. */
|
|
44
|
+
devices: () => Promise<readonly {
|
|
45
|
+
endpoint: string;
|
|
46
|
+
}[]>;
|
|
47
|
+
/** `POST /notifications/test` → how many devices the push service accepted it for. */
|
|
48
|
+
test: () => Promise<{
|
|
49
|
+
sent: number;
|
|
50
|
+
}>;
|
|
51
|
+
/** Whether `register` refused another account's endpoint. Default: HTTP 409. */
|
|
52
|
+
isEndpointTaken?: (error: unknown) => boolean;
|
|
53
|
+
/** After an enable or a disable: the app refreshes its device queries. */
|
|
54
|
+
onChange?: () => void;
|
|
55
|
+
}
|
|
56
|
+
/** What {@link usePushDevice} answers. */
|
|
57
|
+
export interface PushDevice {
|
|
58
|
+
/** The browser exposes service workers, `PushManager` and `Notification`. */
|
|
59
|
+
supported: boolean;
|
|
60
|
+
/** The server has VAPID keys; `undefined` until `pushKey` answered (or when it failed). */
|
|
61
|
+
configured: boolean | undefined;
|
|
62
|
+
/** Running as an installed app: `display-mode: standalone`, or iOS's
|
|
63
|
+
* `navigator.standalone`. */
|
|
64
|
+
installed: boolean;
|
|
65
|
+
/** iPhone or iPad — where push reaches only the installed app. */
|
|
66
|
+
ios: boolean;
|
|
67
|
+
permission: PushPermission;
|
|
68
|
+
/** THIS account holds this browser's subscription. An unreachable device list falls
|
|
69
|
+
* back to the browser's own answer. */
|
|
70
|
+
subscribed: boolean;
|
|
71
|
+
/** This browser holds a subscription that belongs to another account (two accounts in
|
|
72
|
+
* one browser profile): `subscribed` is false, and turning on mints a fresh one. */
|
|
73
|
+
foreignSubscription: boolean;
|
|
74
|
+
/** This browser's subscription endpoint, or `null` — marks "This device" in a list. */
|
|
75
|
+
currentEndpoint: string | null;
|
|
76
|
+
/** An enable or a disable is running. */
|
|
77
|
+
busy: boolean;
|
|
78
|
+
/** Ask for permission (the browser's one prompt), subscribe and register. `false` when
|
|
79
|
+
* the permission was not granted or push is off on the server; rejects with a
|
|
80
|
+
* {@link PushDeviceError} or the app's request's error. */
|
|
81
|
+
enable: () => Promise<boolean>;
|
|
82
|
+
/** Tell the server first, then the browser, so a dropped device stops at once. */
|
|
83
|
+
disable: () => Promise<void>;
|
|
84
|
+
sendTest: () => Promise<{
|
|
85
|
+
sent: number;
|
|
86
|
+
}>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The window event every mounted {@link usePushDevice} re-reads its state on. Fired by
|
|
90
|
+
* every enable and disable, and by `PushDevicesSetting` when it removes this device — so
|
|
91
|
+
* an app's own card on the same hook (keksdose's check-up) stays in step. The hook that
|
|
92
|
+
* fires it does not re-read: it knows what it changed. Namespaced for the kit;
|
|
93
|
+
* keksdose's was `"keksdose:push-subscription-changed"`.
|
|
94
|
+
*/
|
|
95
|
+
export declare const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
|
|
96
|
+
/** Tell every {@link usePushDevice} that this browser's subscription changed. */
|
|
97
|
+
export declare function notifyPushSubscriptionChanged(): void;
|
|
98
|
+
/** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED
|
|
99
|
+
* app (Add to Home Screen). */
|
|
100
|
+
export declare function pushSupported(): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* This browser's push for the signed-in account. Hand it one function per endpoint
|
|
103
|
+
* (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.
|
|
104
|
+
*
|
|
105
|
+
* **"Subscribed" means this account**, not "this browser holds a subscription": a
|
|
106
|
+
* browser profile's subscription has no account in it, so a second account in the same
|
|
107
|
+
* profile would read the first's as its own (keksdose). The endpoint is checked against
|
|
108
|
+
* the account's own `devices()`; when that list cannot be read (offline), the browser's
|
|
109
|
+
* answer stands — a wrong "on" costs what it did before the check, a wrong "off" invites
|
|
110
|
+
* re-subscribing a device that is fine.
|
|
111
|
+
*
|
|
112
|
+
* **A 409 from `register`** — the endpoint belongs to another account, and the server
|
|
113
|
+
* will not re-home what it cannot prove this browser holds — unsubscribes and registers
|
|
114
|
+
* a fresh endpoint, once.
|
|
115
|
+
*
|
|
116
|
+
* **Timeouts:** no registration fails at once and a worker not ready in 10 s fails, both
|
|
117
|
+
* `PushDeviceError("no_service_worker")`; the browser's subscribe and unsubscribe are cut
|
|
118
|
+
* at 15 s, `PushDeviceError("push_timeout")` — so `busy` can never latch.
|
|
119
|
+
*
|
|
120
|
+
* Every mounted hook re-reads its state on {@link PUSH_SUBSCRIPTION_CHANGED}.
|
|
121
|
+
*/
|
|
122
|
+
export declare function usePushDevice(options: UsePushDeviceOptions): PushDevice;
|