@eifi1/ui-kit 0.35.0 → 0.37.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/eslint/role-utilities.d.ts +34 -2
- package/dist/eslint/role-utilities.js +30 -9
- package/dist/eslint/role-utilities.js.map +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 +16 -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/eslint/role-utilities.ts +67 -11
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +113 -0
- package/src/i18n/kit-labels.tsx +52 -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,224 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
5
|
+
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { AlertBanner } from "../components/alert-banner";
|
|
8
|
+
import { Button } from "../components/button";
|
|
9
|
+
import { Card } from "../components/card";
|
|
10
|
+
import { DialogFrame } from "../components/dialog-frame";
|
|
11
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
12
|
+
import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
|
|
13
|
+
import { SettingsCardTitle } from "../settings/settings-heading";
|
|
14
|
+
import { useMounted } from "../account/account-parts";
|
|
15
|
+
import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
|
|
16
|
+
import type { NotificationsLabels } from "./notifications-labels";
|
|
17
|
+
import { PushDeviceError } from "./use-push-device";
|
|
18
|
+
import type { PushDevice } from "./use-push-device";
|
|
19
|
+
|
|
20
|
+
/** Which action failed — {@link PushDeviceSettingProps.describeError}'s second argument. */
|
|
21
|
+
export type PushDeviceAction = "enable" | "disable" | "test";
|
|
22
|
+
|
|
23
|
+
export interface PushDeviceSettingProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
24
|
+
/** This browser's push, from {@link usePushDevice}. */
|
|
25
|
+
push: PushDevice;
|
|
26
|
+
/** The app's name, for the iOS line and the explainer. */
|
|
27
|
+
appName: string;
|
|
28
|
+
/** The app's own words for a failure, or `undefined` for the kit's. The kit already
|
|
29
|
+
* has words for a missing service worker and an unanswered push service. */
|
|
30
|
+
describeError?: (error: unknown, action: PushDeviceAction) => ReactNode | undefined;
|
|
31
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the line under the buttons says. */
|
|
35
|
+
type Outcome = { tone: "status" | "warning" | "danger"; text: ReactNode } | null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Push on THIS device (docs/notifications-harmonization.md §2.2, §2.7, §4.2): keksdose's
|
|
39
|
+
* push block, as a card of its own. Registering the device is the switch — push has no
|
|
40
|
+
* account switch. One state at a time, in keksdose's order
|
|
41
|
+
* (`notifications-card.tsx:333-402`):
|
|
42
|
+
*
|
|
43
|
+
* 1. the browser has no push;
|
|
44
|
+
* 2. iOS outside the installed app (iOS delivers only to the installed app);
|
|
45
|
+
* 3. push is not set up on the server (no VAPID keys);
|
|
46
|
+
* 4. notifications blocked in the browser;
|
|
47
|
+
* 5. on or off — with "another account" when this browser's subscription is someone
|
|
48
|
+
* else's — and "Turn on notifications", or "Send a test" and "Turn off".
|
|
49
|
+
*
|
|
50
|
+
* **The explainer comes before the browser's one prompt**: turning on while the
|
|
51
|
+
* permission is still `"default"` opens a dialog first (what the notifications are for,
|
|
52
|
+
* that the browser asks next); with it granted, it goes straight on. A test that reached
|
|
53
|
+
* no device says so (`testNone`) — a 404/410 removes the row on the server while this
|
|
54
|
+
* browser still holds its subscription. Outcomes are a line under the buttons; nothing
|
|
55
|
+
* toasts.
|
|
56
|
+
*/
|
|
57
|
+
export function PushDeviceSetting({
|
|
58
|
+
push,
|
|
59
|
+
appName,
|
|
60
|
+
describeError,
|
|
61
|
+
labels: labelsProp,
|
|
62
|
+
className,
|
|
63
|
+
...rest
|
|
64
|
+
}: PushDeviceSettingProps) {
|
|
65
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
66
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
67
|
+
const [explainer, setExplainer] = useState(false);
|
|
68
|
+
const [running, setRunning] = useState<PushDeviceAction | null>(null);
|
|
69
|
+
const [outcome, setOutcome] = useState<Outcome>(null);
|
|
70
|
+
const mounted = useMounted();
|
|
71
|
+
|
|
72
|
+
const failureOf = (error: unknown, action: PushDeviceAction, fallback: string): ReactNode => {
|
|
73
|
+
if (error instanceof PushDeviceError) {
|
|
74
|
+
return error.code === "no_service_worker" ? labels.noServiceWorker : labels.pushTimeout;
|
|
75
|
+
}
|
|
76
|
+
return describeFailure(describeError?.(error, action), fallback);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const run = <T,>(action: PushDeviceAction, call: () => Promise<T>, done: (value: T) => Outcome, fallback: string) => {
|
|
80
|
+
if (running) return;
|
|
81
|
+
setRunning(action);
|
|
82
|
+
setOutcome(null);
|
|
83
|
+
call().then(
|
|
84
|
+
(value) => {
|
|
85
|
+
if (!mounted.current) return;
|
|
86
|
+
setRunning(null);
|
|
87
|
+
setOutcome(done(value));
|
|
88
|
+
},
|
|
89
|
+
(error: unknown) => {
|
|
90
|
+
if (!mounted.current) return;
|
|
91
|
+
setRunning(null);
|
|
92
|
+
setOutcome({ tone: "danger", text: failureOf(error, action, fallback) });
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const enable = () =>
|
|
98
|
+
run(
|
|
99
|
+
"enable",
|
|
100
|
+
() => push.enable(),
|
|
101
|
+
(ok): Outcome => (ok ? null : { tone: "danger", text: labels.enableFailed }),
|
|
102
|
+
labels.enableFailed,
|
|
103
|
+
);
|
|
104
|
+
const disable = () => run("disable", () => push.disable(), () => null, labels.failed);
|
|
105
|
+
const test = () =>
|
|
106
|
+
run(
|
|
107
|
+
"test",
|
|
108
|
+
() => push.sendTest(),
|
|
109
|
+
({ sent }): Outcome =>
|
|
110
|
+
sent > 0 ? { tone: "status", text: labels.testSent(sent) } : { tone: "warning", text: labels.testNone },
|
|
111
|
+
labels.testFailed,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const turnOn = () => {
|
|
115
|
+
if (push.permission === "granted") enable();
|
|
116
|
+
else setExplainer(true);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let state: ReactNode;
|
|
120
|
+
if (!push.supported) {
|
|
121
|
+
state = <p className={CARD_DESCRIPTION_CLASS}>{labels.unsupported}</p>;
|
|
122
|
+
} else if (push.ios && !push.installed) {
|
|
123
|
+
state = <p className={CARD_DESCRIPTION_CLASS}>{labels.iosInstall(appName)}</p>;
|
|
124
|
+
} else if (push.configured === false) {
|
|
125
|
+
state = <p className={CARD_DESCRIPTION_CLASS}>{labels.notConfigured}</p>;
|
|
126
|
+
} else if (push.permission === "denied") {
|
|
127
|
+
state = <p className="text-xs text-warning">{labels.blocked}</p>;
|
|
128
|
+
} else {
|
|
129
|
+
const busy = push.busy || running !== null;
|
|
130
|
+
state = (
|
|
131
|
+
<>
|
|
132
|
+
<p className={CARD_DESCRIPTION_CLASS} data-push-state={push.subscribed ? "on" : "off"}>
|
|
133
|
+
{push.subscribed ? labels.on : labels.off}
|
|
134
|
+
</p>
|
|
135
|
+
{push.foreignSubscription && <p className="text-xs text-warning">{labels.otherAccount}</p>}
|
|
136
|
+
<div className="flex flex-wrap gap-2">
|
|
137
|
+
{push.subscribed ? (
|
|
138
|
+
<>
|
|
139
|
+
<Button
|
|
140
|
+
type="button"
|
|
141
|
+
size="sm"
|
|
142
|
+
variant="secondary"
|
|
143
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
144
|
+
pending={running === "test"}
|
|
145
|
+
disabled={busy && running !== "test"}
|
|
146
|
+
onClick={test}
|
|
147
|
+
>
|
|
148
|
+
{labels.sendTest}
|
|
149
|
+
</Button>
|
|
150
|
+
<Button
|
|
151
|
+
type="button"
|
|
152
|
+
size="sm"
|
|
153
|
+
variant="ghost"
|
|
154
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
155
|
+
pending={running === "disable"}
|
|
156
|
+
disabled={busy && running !== "disable"}
|
|
157
|
+
onClick={disable}
|
|
158
|
+
>
|
|
159
|
+
{labels.disable}
|
|
160
|
+
</Button>
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<Button
|
|
164
|
+
type="button"
|
|
165
|
+
size="sm"
|
|
166
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
167
|
+
pending={running === "enable"}
|
|
168
|
+
disabled={busy && running !== "enable"}
|
|
169
|
+
onClick={turnOn}
|
|
170
|
+
>
|
|
171
|
+
{labels.enable}
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
{outcome?.tone === "status" && (
|
|
176
|
+
<p role="status" className={CARD_DESCRIPTION_CLASS}>
|
|
177
|
+
{outcome.text}
|
|
178
|
+
</p>
|
|
179
|
+
)}
|
|
180
|
+
{outcome?.tone === "warning" && (
|
|
181
|
+
<AlertBanner tone="warning" size="sm" role="status">
|
|
182
|
+
{outcome.text}
|
|
183
|
+
</AlertBanner>
|
|
184
|
+
)}
|
|
185
|
+
{outcome?.tone === "danger" && <FailureLine>{outcome.text}</FailureLine>}
|
|
186
|
+
</>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<Card {...rest} className={cn("p-4 space-y-3", className)}>
|
|
192
|
+
<div>
|
|
193
|
+
<SettingsCardTitle>{labels.pushTitle}</SettingsCardTitle>
|
|
194
|
+
<div className={CARD_DESCRIPTION_CLASS}>{labels.pushDescription}</div>
|
|
195
|
+
</div>
|
|
196
|
+
{state}
|
|
197
|
+
<DialogFrame
|
|
198
|
+
open={explainer}
|
|
199
|
+
onClose={() => setExplainer(false)}
|
|
200
|
+
title={labels.explainerTitle}
|
|
201
|
+
description={labels.explainerBody(appName)}
|
|
202
|
+
actions={(close) => (
|
|
203
|
+
<div className="flex justify-end gap-2">
|
|
204
|
+
<Button type="button" variant="ghost" onClick={close}>
|
|
205
|
+
{common.cancel}
|
|
206
|
+
</Button>
|
|
207
|
+
<Button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setExplainer(false);
|
|
211
|
+
// Only now the browser's one permission prompt.
|
|
212
|
+
enable();
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
{labels.explainerContinue}
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
<p className={CARD_DESCRIPTION_CLASS}>{labels.explainerHint}</p>
|
|
221
|
+
</DialogFrame>
|
|
222
|
+
</Card>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
import { MonitorSmartphone } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
|
|
6
|
+
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
import { formatDate } from "../lib/format";
|
|
9
|
+
import { Button } from "../components/button";
|
|
10
|
+
import { Card } from "../components/card";
|
|
11
|
+
import { Chip } from "../components/chip";
|
|
12
|
+
import { Skeleton } from "../components/skeleton";
|
|
13
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
14
|
+
import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
|
|
15
|
+
import { SettingsCardTitle } from "../settings/settings-heading";
|
|
16
|
+
import { settle, useMounted } from "../account/account-parts";
|
|
17
|
+
import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
|
|
18
|
+
import type { NotificationsLabels } from "./notifications-labels";
|
|
19
|
+
import type { PushDeviceItem } from "./notification-types";
|
|
20
|
+
import { notifyPushSubscriptionChanged } from "./use-push-device";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A name for a stored user agent: "Chrome · Android", "Firefox · Windows" (keksdose
|
|
24
|
+
* `push-devices-card.tsx:19-45`). Best effort: an agent it does not recognise comes back
|
|
25
|
+
* as it is, cut at 60 characters so a token soup cannot blow up the row; `null` for none.
|
|
26
|
+
* The order matters — Edge and Opera say "Chrome" too, and Chrome says "Safari".
|
|
27
|
+
*/
|
|
28
|
+
export function describeUserAgent(ua: string | null): string | null {
|
|
29
|
+
if (!ua) return null;
|
|
30
|
+
const browser = /edg(?:e|a|ios)?\//i.test(ua)
|
|
31
|
+
? "Edge"
|
|
32
|
+
: /opr\/|opera/i.test(ua)
|
|
33
|
+
? "Opera"
|
|
34
|
+
: /firefox|fxios/i.test(ua)
|
|
35
|
+
? "Firefox"
|
|
36
|
+
: /chrome|crios/i.test(ua)
|
|
37
|
+
? "Chrome"
|
|
38
|
+
: /safari/i.test(ua)
|
|
39
|
+
? "Safari"
|
|
40
|
+
: null;
|
|
41
|
+
const os = /windows/i.test(ua)
|
|
42
|
+
? "Windows"
|
|
43
|
+
: /android/i.test(ua)
|
|
44
|
+
? "Android"
|
|
45
|
+
: /iphone|ipad|ipod/i.test(ua)
|
|
46
|
+
? "iOS"
|
|
47
|
+
: /mac os x|macintosh/i.test(ua)
|
|
48
|
+
? "macOS"
|
|
49
|
+
: /linux/i.test(ua)
|
|
50
|
+
? "Linux"
|
|
51
|
+
: null;
|
|
52
|
+
if (!browser && !os) return ua.length > 60 ? `${ua.slice(0, 60)}…` : ua;
|
|
53
|
+
return [browser, os].filter(Boolean).join(" · ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PushDevicesSettingProps<Id extends string | number = string>
|
|
57
|
+
extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
58
|
+
/** `GET /notifications/devices`, oldest first; `undefined` while the first load runs. */
|
|
59
|
+
devices: readonly PushDeviceItem<Id>[] | undefined;
|
|
60
|
+
loading?: boolean;
|
|
61
|
+
/** This browser's endpoint (`usePushDevice().currentEndpoint`): its row says "This
|
|
62
|
+
* device", and removing it also unsubscribes the browser. */
|
|
63
|
+
currentEndpoint?: string | null;
|
|
64
|
+
/** `DELETE /notifications/devices/{id}`. Asked first; refresh `devices` once it
|
|
65
|
+
* resolves. */
|
|
66
|
+
onRemove: (id: Id) => Promise<unknown> | void;
|
|
67
|
+
/** The dates' text. Default: the provider's locale, medium date. */
|
|
68
|
+
formatDate?: (date: Date) => string;
|
|
69
|
+
/** Overrides the provider's locale for the default format. */
|
|
70
|
+
locale?: string;
|
|
71
|
+
/** The app's own words for a failed removal, or `undefined` for the kit's `failed`. */
|
|
72
|
+
describeError?: (error: unknown) => ReactNode | undefined;
|
|
73
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Tear down this browser's subscription after its server row went: otherwise the
|
|
77
|
+
* browser still holds one, and the push card would say "on" for a device nothing can
|
|
78
|
+
* reach. Best effort — the row is already gone. */
|
|
79
|
+
async function unsubscribeThisBrowser(): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
const registration = await navigator.serviceWorker?.getRegistration();
|
|
82
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
83
|
+
await subscription?.unsubscribe();
|
|
84
|
+
} catch {
|
|
85
|
+
/* the server row is gone either way */
|
|
86
|
+
}
|
|
87
|
+
notifyPushSubscriptionChanged();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every device that gets push for the account (docs/notifications-harmonization.md §3.5,
|
|
92
|
+
* §4.2) — keksdose's devices card: the browser and system read from the stored user
|
|
93
|
+
* agent, "This device" on this browser's row, when it was added and last delivered to,
|
|
94
|
+
* the failures in a row in the warning tone, and "Remove".
|
|
95
|
+
*
|
|
96
|
+
* **Remove asks first, in the row**, as `PasskeysSetting` does: the card must work in an
|
|
97
|
+
* app without a `ConfirmProvider`, and the question belongs beside the device it names.
|
|
98
|
+
* Focus moves to Cancel. **Removing this device** also unsubscribes the browser and fires
|
|
99
|
+
* `PUSH_SUBSCRIPTION_CHANGED` (keksdose `push-devices-card.tsx:83-97`), so the push card
|
|
100
|
+
* stops saying "on".
|
|
101
|
+
*
|
|
102
|
+
* Under a write lock Remove is a `commit` control exempt from billing.
|
|
103
|
+
*/
|
|
104
|
+
export function PushDevicesSetting<Id extends string | number = string>({
|
|
105
|
+
devices,
|
|
106
|
+
loading,
|
|
107
|
+
currentEndpoint,
|
|
108
|
+
onRemove,
|
|
109
|
+
formatDate: formatDateProp,
|
|
110
|
+
locale: localeProp,
|
|
111
|
+
describeError,
|
|
112
|
+
labels: labelsProp,
|
|
113
|
+
className,
|
|
114
|
+
...rest
|
|
115
|
+
}: PushDevicesSettingProps<Id>) {
|
|
116
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
117
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
118
|
+
const locale = useKitLocale(localeProp);
|
|
119
|
+
const [asking, setAsking] = useState<Id | null>(null);
|
|
120
|
+
const [busyId, setBusyId] = useState<Id | null>(null);
|
|
121
|
+
const [failure, setFailure] = useState<ReactNode>(null);
|
|
122
|
+
const mounted = useMounted();
|
|
123
|
+
|
|
124
|
+
const dateText = (iso: string): string => {
|
|
125
|
+
const date = new Date(iso);
|
|
126
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
127
|
+
return formatDateProp ? formatDateProp(date) : formatDate(date, "medium", { locale });
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const remove = (device: PushDeviceItem<Id>) => {
|
|
131
|
+
if (busyId !== null) return;
|
|
132
|
+
setBusyId(device.id);
|
|
133
|
+
setFailure(null);
|
|
134
|
+
const isThis = Boolean(currentEndpoint) && device.endpoint === currentEndpoint;
|
|
135
|
+
settle(() => onRemove(device.id))
|
|
136
|
+
.then(async () => {
|
|
137
|
+
if (isThis) await unsubscribeThisBrowser();
|
|
138
|
+
if (!mounted.current) return;
|
|
139
|
+
setBusyId(null);
|
|
140
|
+
setAsking(null);
|
|
141
|
+
})
|
|
142
|
+
.catch((error: unknown) => {
|
|
143
|
+
if (!mounted.current) return;
|
|
144
|
+
setBusyId(null);
|
|
145
|
+
setFailure(describeFailure(describeError?.(error), labels.failed));
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let body: ReactNode;
|
|
150
|
+
if (loading || devices === undefined) {
|
|
151
|
+
body = <Skeleton lines={2} label={labels.devicesLoading} />;
|
|
152
|
+
} else if (devices.length === 0) {
|
|
153
|
+
body = <p className={CARD_DESCRIPTION_CLASS}>{labels.devicesEmpty}</p>;
|
|
154
|
+
} else {
|
|
155
|
+
body = (
|
|
156
|
+
<ul aria-label={labels.devicesTitle} className="divide-y divide-subtle">
|
|
157
|
+
{devices.map((device) => (
|
|
158
|
+
<DeviceRow
|
|
159
|
+
key={device.id}
|
|
160
|
+
device={device}
|
|
161
|
+
name={describeUserAgent(device.user_agent) ?? labels.unknownDevice}
|
|
162
|
+
current={Boolean(currentEndpoint) && device.endpoint === currentEndpoint}
|
|
163
|
+
added={dateText(device.created_at)}
|
|
164
|
+
lastDelivery={device.last_success_at ? dateText(device.last_success_at) : null}
|
|
165
|
+
asking={asking === device.id}
|
|
166
|
+
busy={busyId === device.id}
|
|
167
|
+
blocked={busyId !== null && busyId !== device.id}
|
|
168
|
+
onAsk={() => setAsking(device.id)}
|
|
169
|
+
onCancel={() => setAsking(null)}
|
|
170
|
+
onConfirm={() => remove(device)}
|
|
171
|
+
labels={labels}
|
|
172
|
+
cancelLabel={common.cancel}
|
|
173
|
+
/>
|
|
174
|
+
))}
|
|
175
|
+
</ul>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Card {...rest} className={cn("p-4 space-y-3", className)}>
|
|
181
|
+
<div>
|
|
182
|
+
<SettingsCardTitle>{labels.devicesTitle}</SettingsCardTitle>
|
|
183
|
+
<div className={CARD_DESCRIPTION_CLASS}>{labels.devicesDescription}</div>
|
|
184
|
+
</div>
|
|
185
|
+
{body}
|
|
186
|
+
<FailureLine>{failure}</FailureLine>
|
|
187
|
+
</Card>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function DeviceRow<Id extends string | number>({
|
|
192
|
+
device,
|
|
193
|
+
name,
|
|
194
|
+
current,
|
|
195
|
+
added,
|
|
196
|
+
lastDelivery,
|
|
197
|
+
asking,
|
|
198
|
+
busy,
|
|
199
|
+
blocked,
|
|
200
|
+
onAsk,
|
|
201
|
+
onCancel,
|
|
202
|
+
onConfirm,
|
|
203
|
+
labels,
|
|
204
|
+
cancelLabel,
|
|
205
|
+
}: {
|
|
206
|
+
device: PushDeviceItem<Id>;
|
|
207
|
+
name: string;
|
|
208
|
+
current: boolean;
|
|
209
|
+
added: string;
|
|
210
|
+
lastDelivery: string | null;
|
|
211
|
+
asking: boolean;
|
|
212
|
+
busy: boolean;
|
|
213
|
+
blocked: boolean;
|
|
214
|
+
onAsk: () => void;
|
|
215
|
+
onCancel: () => void;
|
|
216
|
+
onConfirm: () => void;
|
|
217
|
+
labels: NotificationsLabels;
|
|
218
|
+
cancelLabel: string;
|
|
219
|
+
}) {
|
|
220
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
221
|
+
const removeRef = useRef<HTMLButtonElement>(null);
|
|
222
|
+
// Which way the confirm closed: back to Remove, so focus does not fall to <body>.
|
|
223
|
+
const [returning, setReturning] = useState(false);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (asking) cancelRef.current?.focus();
|
|
226
|
+
else if (returning) removeRef.current?.focus();
|
|
227
|
+
}, [asking, returning]);
|
|
228
|
+
|
|
229
|
+
const leave = () => {
|
|
230
|
+
setReturning(true);
|
|
231
|
+
onCancel();
|
|
232
|
+
};
|
|
233
|
+
const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
234
|
+
if (event.key === "Escape") {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
event.stopPropagation();
|
|
237
|
+
leave();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const row = { "data-push-device-id": String(device.id), "data-current": current || undefined };
|
|
242
|
+
|
|
243
|
+
if (asking) {
|
|
244
|
+
return (
|
|
245
|
+
<li {...row} className="space-y-2 py-2">
|
|
246
|
+
<p role="alert" className="text-sm">
|
|
247
|
+
{labels.removePrompt}
|
|
248
|
+
</p>
|
|
249
|
+
<div className="flex flex-wrap gap-2">
|
|
250
|
+
<Button
|
|
251
|
+
type="button"
|
|
252
|
+
size="sm"
|
|
253
|
+
variant="danger"
|
|
254
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
255
|
+
pending={busy}
|
|
256
|
+
aria-label={labels.removeItem(name)}
|
|
257
|
+
onClick={onConfirm}
|
|
258
|
+
onKeyDown={escapeLeaves}
|
|
259
|
+
>
|
|
260
|
+
{labels.remove}
|
|
261
|
+
</Button>
|
|
262
|
+
<Button ref={cancelRef} type="button" size="sm" variant="ghost" onClick={leave} onKeyDown={escapeLeaves}>
|
|
263
|
+
{cancelLabel}
|
|
264
|
+
</Button>
|
|
265
|
+
</div>
|
|
266
|
+
</li>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<li {...row} className="flex items-center gap-3 py-2">
|
|
272
|
+
<MonitorSmartphone aria-hidden className="size-4 shrink-0 text-muted" />
|
|
273
|
+
<div className="min-w-0 flex-1">
|
|
274
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
275
|
+
<span className="min-w-0 break-words text-sm font-medium">{name}</span>
|
|
276
|
+
{current && (
|
|
277
|
+
<Chip size="sm" tone="brand">
|
|
278
|
+
{labels.thisDevice}
|
|
279
|
+
</Chip>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
<div className={CARD_DESCRIPTION_CLASS}>
|
|
283
|
+
{labels.added(added)}
|
|
284
|
+
{" · "}
|
|
285
|
+
{lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}
|
|
286
|
+
</div>
|
|
287
|
+
{device.failure_count > 0 && <div className="text-xs text-warning">{labels.failures(device.failure_count)}</div>}
|
|
288
|
+
</div>
|
|
289
|
+
<Button
|
|
290
|
+
ref={removeRef}
|
|
291
|
+
type="button"
|
|
292
|
+
size="sm"
|
|
293
|
+
variant="ghost"
|
|
294
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
295
|
+
disabled={blocked}
|
|
296
|
+
aria-label={labels.removeItem(name)}
|
|
297
|
+
onClick={() => {
|
|
298
|
+
setReturning(false);
|
|
299
|
+
onAsk();
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
{labels.remove}
|
|
303
|
+
</Button>
|
|
304
|
+
</li>
|
|
305
|
+
);
|
|
306
|
+
}
|