@eifi1/ui-kit 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -12
- package/dist/admin/admin-area-labels.d.ts +9 -0
- package/dist/admin/admin-area-labels.js +5 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +16 -1
- package/dist/admin/admin-overview.js +12 -1
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/mail-test-card.d.ts +3 -2
- package/dist/admin/mail-test-card.js +3 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/components/appearance-labels.d.ts +10 -0
- package/dist/components/appearance-labels.js +6 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-head.js +2 -2
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +18 -2
- package/dist/components/data-table-row-actions.js +86 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-shared.js +9 -2
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +43 -2
- package/dist/components/data-table.js +9 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-format-setting.d.ts +29 -0
- package/dist/components/date-format-setting.js +36 -0
- package/dist/components/date-format-setting.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +8 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tabs.d.ts +3 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -0
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +5 -0
- package/dist/components/translation-review-labels.js +2 -0
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +18 -8
- package/dist/components/translation-review.js +64 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/write-lock-labels.js +2 -1
- package/dist/components/write-lock-labels.js.map +1 -1
- package/dist/components/write-lock.d.ts +33 -3
- package/dist/components/write-lock.js +9 -0
- package/dist/components/write-lock.js.map +1 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +4 -2
- package/dist/feedback/feedback-row-detail.js +17 -26
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-swipe.d.ts +18 -0
- package/dist/feedback/feedback-swipe.js +10 -1
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/i18n/german.js +41 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +12 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +40 -6
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +41 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +41 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +41 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +41 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +41 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +3 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/date-format.d.ts +81 -0
- package/dist/lib/date-format.js +56 -0
- package/dist/lib/date-format.js.map +1 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/format.js +5 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +27 -0
- package/dist/notifications/device-zone-report.js +24 -0
- package/dist/notifications/device-zone-report.js.map +1 -0
- package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
- package/dist/notifications/feedback-notification-types-labels.js +4 -1
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +11 -5
- package/dist/notifications/feedback-notification-types.js +2 -1
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +33 -0
- package/dist/notifications/notification-bell.js +74 -0
- package/dist/notifications/notification-bell.js.map +1 -0
- package/dist/notifications/notification-inbox.d.ts +77 -0
- package/dist/notifications/notification-inbox.js +206 -0
- package/dist/notifications/notification-inbox.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +6 -4
- package/dist/notifications/notification-types-setting.js +103 -45
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +19 -7
- package/dist/notifications/notifications-labels.d.ts +41 -7
- package/dist/notifications/notifications-labels.js +18 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-devices-setting.d.ts +20 -3
- package/dist/notifications/push-devices-setting.js +93 -33
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.d.ts +108 -0
- package/dist/notifications/push-worker.js +62 -0
- package/dist/notifications/push-worker.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +29 -17
- package/dist/notifications/quiet-hours-setting.js +9 -41
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +5 -2
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/notifications/use-push-device.d.ts +4 -17
- package/dist/notifications/use-push-device.js +7 -15
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/local-first.d.ts +33 -0
- package/dist/offline/local-first.js +39 -0
- package/dist/offline/local-first.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +26 -1
- package/dist/offline/query-persistence.js +27 -23
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline/service-worker-updates.d.ts +46 -0
- package/dist/offline/service-worker-updates.js +39 -0
- package/dist/offline/service-worker-updates.js.map +1 -0
- package/dist/offline.d.ts +5 -2
- package/dist/offline.js +11 -2
- package/dist/offline.js.map +1 -1
- package/dist/push-worker.d.ts +2 -0
- package/dist/push-worker.js +14 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/shell/account-menu-text-size.d.ts +33 -0
- package/dist/shell/account-menu-text-size.js +51 -0
- package/dist/shell/account-menu-text-size.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +6 -2
- package/dist/shell/top-bar-actions.js +1 -0
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/sync/db.d.ts +76 -0
- package/dist/sync/db.js +24 -0
- package/dist/sync/db.js.map +1 -0
- package/dist/sync/engine.d.ts +124 -0
- package/dist/sync/engine.js +474 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hooks.d.ts +30 -0
- package/dist/sync/hooks.js +67 -0
- package/dist/sync/hooks.js.map +1 -0
- package/dist/sync/testing.d.ts +28 -0
- package/dist/sync/testing.js +41 -0
- package/dist/sync/testing.js.map +1 -0
- package/dist/sync/wire.d.ts +50 -0
- package/dist/sync/wire.js +2 -0
- package/dist/sync/wire.js.map +1 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.js +22 -0
- package/dist/sync.js.map +1 -0
- package/dist/tour/tour-labels.js +1 -0
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +30 -0
- package/dist/tour/tour.js +50 -6
- package/dist/tour/tour.js.map +1 -1
- package/package.json +19 -1
- package/src/admin/admin-area-labels.ts +13 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card.tsx +7 -3
- package/src/components/appearance-labels.ts +16 -0
- package/src/components/data-table-column-settings.tsx +2 -2
- package/src/components/data-table-head.tsx +2 -2
- package/src/components/data-table-row-actions.tsx +118 -2
- package/src/components/data-table-shared.tsx +21 -2
- package/src/components/data-table.tsx +51 -6
- package/src/components/date-format-setting.tsx +64 -0
- package/src/components/swipeable-row.tsx +8 -0
- package/src/components/tabs.tsx +4 -3
- package/src/components/translation-review-editor.tsx +2 -0
- package/src/components/translation-review-labels.ts +7 -0
- package/src/components/translation-review.tsx +102 -14
- package/src/components/write-lock-labels.ts +1 -0
- package/src/components/write-lock.tsx +36 -3
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +18 -23
- package/src/feedback/feedback-swipe.tsx +28 -1
- package/src/i18n/german.ts +44 -4
- package/src/i18n/kit-labels.tsx +18 -1
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/es.ts +44 -4
- package/src/i18n/locales/fr.ts +44 -4
- package/src/i18n/locales/hu.ts +44 -4
- package/src/i18n/locales/it.ts +44 -4
- package/src/i18n/locales/zh.ts +44 -2
- package/src/i18n/review.ts +3 -1
- package/src/index.ts +42 -0
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +10 -4
- package/src/notifications/device-zone-report.ts +54 -0
- package/src/notifications/feedback-notification-types-labels.ts +10 -1
- package/src/notifications/feedback-notification-types.ts +12 -5
- package/src/notifications/notification-bell.tsx +116 -0
- package/src/notifications/notification-inbox.tsx +304 -0
- package/src/notifications/notification-types-setting.tsx +151 -55
- package/src/notifications/notification-types.ts +20 -8
- package/src/notifications/notifications-labels.ts +69 -11
- package/src/notifications/push-devices-setting.tsx +123 -34
- package/src/notifications/push-worker.ts +160 -0
- package/src/notifications/quiet-hours-setting.tsx +41 -58
- package/src/notifications/time-zone-select.tsx +5 -2
- package/src/notifications/use-push-device.ts +20 -33
- package/src/offline/local-first.ts +76 -0
- package/src/offline/query-persistence.ts +57 -24
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline.ts +7 -1
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +3 -0
- package/src/sync/db.ts +103 -0
- package/src/sync/engine.ts +699 -0
- package/src/sync/hooks.ts +93 -0
- package/src/sync/testing.ts +73 -0
- package/src/sync/wire.ts +61 -0
- package/src/sync.ts +29 -0
- package/src/tour/tour-labels.ts +1 -0
- package/src/tour/tour.tsx +100 -7
|
@@ -3,7 +3,6 @@ import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import type { LabelOverride } from "../i18n/kit-labels";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
-
import { Button } from "../components/button";
|
|
7
6
|
import { Card } from "../components/card";
|
|
8
7
|
import { TimeInput } from "../components/time-input";
|
|
9
8
|
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
@@ -13,9 +12,9 @@ import { settle, useMounted } from "../account/account-parts";
|
|
|
13
12
|
import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts";
|
|
14
13
|
import type { NotificationsLabels } from "./notifications-labels";
|
|
15
14
|
import type { NotificationSettingsPatch } from "./notification-types";
|
|
16
|
-
import {
|
|
15
|
+
import { zoneName } from "./time-zone-select";
|
|
17
16
|
|
|
18
|
-
/** The zone the server applies while
|
|
17
|
+
/** The zone the server applies while no device ever reported one (§10.3): every app's. */
|
|
19
18
|
const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
|
|
20
19
|
|
|
21
20
|
export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div">, "children" | "onChange"> {
|
|
@@ -23,17 +22,29 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
|
|
|
23
22
|
start: string | null;
|
|
24
23
|
/** The `GET`'s `quiet_hours_end`. */
|
|
25
24
|
end: string | null;
|
|
26
|
-
/** The
|
|
27
|
-
|
|
25
|
+
/** The zone the 0.36 card showed, an IANA name, or `null`.
|
|
26
|
+
* @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.
|
|
27
|
+
* Shown read-only while `zone` is left out; the card no longer offers a picker. */
|
|
28
|
+
timezone?: string | null;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
* the
|
|
31
|
-
*
|
|
30
|
+
* 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s
|
|
31
|
+
* `device_timezone`, the zone of the device last used; `null` while no device has
|
|
32
|
+
* reported one, and the card then names `fallbackZone`, the zone the server applies.
|
|
33
|
+
* Shown read-only once a time is set ("In your time zone (Europe / Zurich), which follows
|
|
34
|
+
* the device you last used."). No picker: the zone is never chosen.
|
|
35
|
+
*/
|
|
36
|
+
zone?: string | null;
|
|
37
|
+
/** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs
|
|
38
|
+
* the notifications tick); the description says so. Default `false`: dropped. */
|
|
39
|
+
held?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.
|
|
42
|
+
* Never a zone. The field shows the new value while the call runs and goes back if it
|
|
32
43
|
* rejects.
|
|
33
44
|
*/
|
|
34
45
|
onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
|
|
35
|
-
/** The zone
|
|
36
|
-
* Default `"Europe/Zurich"`, every app's
|
|
46
|
+
/** The zone named while neither `zone` nor `timezone` names one: the one the server
|
|
47
|
+
* applies. Default `"Europe/Zurich"`, every app's. */
|
|
37
48
|
fallbackZone?: string;
|
|
38
49
|
/** The app keeps an inbox, and the description says a held push still reaches it. */
|
|
39
50
|
inbox?: boolean;
|
|
@@ -43,30 +54,32 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
|
|
|
43
54
|
labels?: LabelOverride<NotificationsLabels>;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
type QuietField = "quiet_hours_start" | "quiet_hours_end"
|
|
57
|
+
type QuietField = "quiet_hours_start" | "quiet_hours_end";
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local
|
|
50
61
|
* times in the account's zone, which may cross midnight, when no push of a machine-sent
|
|
51
|
-
* type is sent. A held push is dropped,
|
|
52
|
-
* usual. keksdose's block, as a card of its
|
|
62
|
+
* type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the
|
|
63
|
+
* window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its
|
|
64
|
+
* own.
|
|
65
|
+
*
|
|
66
|
+
* **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic
|
|
67
|
+
* only** — the zone of the device last used, else `Europe/Zurich`. The card names it
|
|
68
|
+
* read-only and never sends one; there is no picker and no "Use it".
|
|
53
69
|
*
|
|
54
70
|
* - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
|
|
55
71
|
* one is set the card says so: the window fails open until both are.
|
|
56
|
-
* - **The zone** shows while a time is set
|
|
57
|
-
*
|
|
58
|
-
* (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows
|
|
59
|
-
* `fallbackZone` — the zone the server then applies — never the device's (keksdose's
|
|
60
|
-
* card showed the device's zone while its server applied UTC).
|
|
61
|
-
* - **"This device is in …"** with "Use it" when the device's zone is not the one in
|
|
62
|
-
* effect. Never written on its own: a zone is written by a person's pick (§2.6).
|
|
72
|
+
* - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else
|
|
73
|
+
* `fallbackZone`.
|
|
63
74
|
*
|
|
64
75
|
* Under a write lock the fields are `commit` controls exempt from billing.
|
|
65
76
|
*/
|
|
66
77
|
export function QuietHoursSetting({
|
|
67
78
|
start,
|
|
68
79
|
end,
|
|
69
|
-
timezone,
|
|
80
|
+
timezone = null,
|
|
81
|
+
zone,
|
|
82
|
+
held = false,
|
|
70
83
|
onChange,
|
|
71
84
|
fallbackZone = DEFAULT_FALLBACK_ZONE,
|
|
72
85
|
inbox = false,
|
|
@@ -77,7 +90,7 @@ export function QuietHoursSetting({
|
|
|
77
90
|
...rest
|
|
78
91
|
}: QuietHoursSettingProps) {
|
|
79
92
|
const labels = useNotificationsLabels(labelsProp);
|
|
80
|
-
const overlay = useWriteOverlay<string | null>(`${start}|${end}
|
|
93
|
+
const overlay = useWriteOverlay<string | null>(`${start}|${end}`);
|
|
81
94
|
const [failure, setFailure] = useState<ReactNode>(null);
|
|
82
95
|
const mounted = useMounted();
|
|
83
96
|
const id = useId();
|
|
@@ -86,9 +99,7 @@ export function QuietHoursSetting({
|
|
|
86
99
|
overlay.has(field) ? (overlay.get(field) ?? null) : server;
|
|
87
100
|
const startShown = shown("quiet_hours_start", start);
|
|
88
101
|
const endShown = shown("quiet_hours_end", end);
|
|
89
|
-
const
|
|
90
|
-
const effectiveZone = zoneShown ?? fallbackZone;
|
|
91
|
-
const device = deviceTimeZone();
|
|
102
|
+
const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;
|
|
92
103
|
|
|
93
104
|
const save = (patch: NotificationSettingsPatch) => {
|
|
94
105
|
const fields = Object.keys(patch) as QuietField[];
|
|
@@ -109,9 +120,7 @@ export function QuietHoursSetting({
|
|
|
109
120
|
const saveTime = (field: "quiet_hours_start" | "quiet_hours_end", value: string) => {
|
|
110
121
|
const next = value || null;
|
|
111
122
|
if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
|
|
112
|
-
|
|
113
|
-
if (next && zoneShown === null) patch.timezone = device;
|
|
114
|
-
save(patch);
|
|
123
|
+
save({ [field]: next });
|
|
115
124
|
};
|
|
116
125
|
|
|
117
126
|
const anySet = Boolean(startShown || endShown);
|
|
@@ -121,7 +130,7 @@ export function QuietHoursSetting({
|
|
|
121
130
|
<Card {...rest} className={cn("p-4 space-y-3", className)}>
|
|
122
131
|
<div>
|
|
123
132
|
<SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>
|
|
124
|
-
<div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox)}</div>
|
|
133
|
+
<div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>
|
|
125
134
|
</div>
|
|
126
135
|
<div className="flex flex-wrap items-end gap-3">
|
|
127
136
|
<TimeInput
|
|
@@ -149,35 +158,9 @@ export function QuietHoursSetting({
|
|
|
149
158
|
</p>
|
|
150
159
|
)}
|
|
151
160
|
{anySet && (
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
value={effectiveZone}
|
|
156
|
-
disabled={disabled}
|
|
157
|
-
commit={COMMIT_EXCEPT_BILLING}
|
|
158
|
-
onChange={(zone) => save({ timezone: zone })}
|
|
159
|
-
className="max-w-72"
|
|
160
|
-
labels={labelsProp}
|
|
161
|
-
/>
|
|
162
|
-
{device !== effectiveZone && (
|
|
163
|
-
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
164
|
-
<span id={`${id}-device`} className={CARD_DESCRIPTION_CLASS}>
|
|
165
|
-
{labels.deviceZone(zoneName(device, labels.regions))}
|
|
166
|
-
</span>
|
|
167
|
-
<Button
|
|
168
|
-
type="button"
|
|
169
|
-
size="sm"
|
|
170
|
-
variant="link"
|
|
171
|
-
disabled={disabled}
|
|
172
|
-
commit={COMMIT_EXCEPT_BILLING}
|
|
173
|
-
aria-describedby={`${id}-device`}
|
|
174
|
-
onClick={() => save({ timezone: device })}
|
|
175
|
-
>
|
|
176
|
-
{labels.useDeviceZone}
|
|
177
|
-
</Button>
|
|
178
|
-
</div>
|
|
179
|
-
)}
|
|
180
|
-
</div>
|
|
161
|
+
<p className={CARD_DESCRIPTION_CLASS} data-quiet-zone="">
|
|
162
|
+
{labels.quietZone(zoneName(accountZone, labels.regions))}
|
|
163
|
+
</p>
|
|
181
164
|
)}
|
|
182
165
|
<FailureLine>{failure}</FailureLine>
|
|
183
166
|
</Card>
|
|
@@ -10,8 +10,8 @@ import type { NotificationRegionLabels, NotificationsLabels } from "./notificati
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
|
|
13
|
-
* the zone
|
|
14
|
-
*
|
|
13
|
+
* the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
|
|
14
|
+
* only).
|
|
15
15
|
*/
|
|
16
16
|
export function deviceTimeZone(): string {
|
|
17
17
|
try {
|
|
@@ -40,6 +40,7 @@ function cityOf(rest: string): string {
|
|
|
40
40
|
* rather than trailing the last group as if it were Pacific. `current` is kept even when
|
|
41
41
|
* the runtime does not list it (an old alias such as "Europe/Kiev"): a picker without its
|
|
42
42
|
* own value would show an empty field.
|
|
43
|
+
* @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
|
|
43
44
|
*/
|
|
44
45
|
export function timeZoneOptions(
|
|
45
46
|
current: string,
|
|
@@ -73,6 +74,7 @@ export function zoneName(zone: string, regions: NotificationRegionLabels): strin
|
|
|
73
74
|
return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47. */
|
|
76
78
|
export interface TimeZoneSelectProps {
|
|
77
79
|
/** The IANA zone. Not clearable: a zone can be changed, never emptied. */
|
|
78
80
|
value: string;
|
|
@@ -95,6 +97,7 @@ export interface TimeZoneSelectProps {
|
|
|
95
97
|
* a search box instead of a 400-row wheel. Rows are the city under its region, the region
|
|
96
98
|
* in the kit's words (`labels.regions`); a zone with no region first; a stored zone the
|
|
97
99
|
* runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.
|
|
100
|
+
* @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
|
|
98
101
|
*/
|
|
99
102
|
export function TimeZoneSelect({
|
|
100
103
|
value,
|
|
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
4
4
|
import { httpStatusOf } from "../auth/auth-errors";
|
|
5
|
+
import { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from "./push-worker";
|
|
6
|
+
import type { PushSubscriptionInput } from "./push-worker";
|
|
7
|
+
|
|
8
|
+
export { PUSH_SUBSCRIPTION_CHANGED };
|
|
9
|
+
export type { PushSubscriptionInput };
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* This browser's Web Push subscription for the signed-in account
|
|
@@ -34,13 +39,6 @@ export class PushDeviceError extends Error {
|
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
/** What `register` sends: `POST /notifications/devices` (§3.5). */
|
|
38
|
-
export interface PushSubscriptionInput {
|
|
39
|
-
endpoint: string;
|
|
40
|
-
keys: { p256dh: string; auth: string };
|
|
41
|
-
user_agent: string | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
42
|
export interface UsePushDeviceOptions {
|
|
45
43
|
/** `GET /notifications/push-key` → its `key`; `""` when push is off on the server. */
|
|
46
44
|
pushKey: () => Promise<string>;
|
|
@@ -92,14 +90,15 @@ export interface PushDevice {
|
|
|
92
90
|
sendTest: () => Promise<{ sent: number }>;
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* an app's own card on the same
|
|
99
|
-
* fires it does not re-read: it
|
|
100
|
-
*
|
|
93
|
+
/*
|
|
94
|
+
* PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted
|
|
95
|
+
* {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by
|
|
96
|
+
* `PushDevicesSetting` when it removes this device — so an app's own card on the same
|
|
97
|
+
* hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it
|
|
98
|
+
* knows what it changed. Since 0.46 also the message a service worker posts after a
|
|
99
|
+
* rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced
|
|
100
|
+
* for the kit; keksdose's was `"keksdose:push-subscription-changed"`.
|
|
101
101
|
*/
|
|
102
|
-
export const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
|
|
103
102
|
|
|
104
103
|
/** Tell every {@link usePushDevice} that this browser's subscription changed. */
|
|
105
104
|
export function notifyPushSubscriptionChanged(): void {
|
|
@@ -151,25 +150,6 @@ async function activeRegistration(): Promise<ServiceWorkerRegistration> {
|
|
|
151
150
|
return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, "no_service_worker");
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
/** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
|
|
155
|
-
function keyBytes(key: string): Uint8Array<ArrayBuffer> {
|
|
156
|
-
const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
|
|
157
|
-
const raw = atob(base64);
|
|
158
|
-
const bytes = new Uint8Array(new ArrayBuffer(raw.length));
|
|
159
|
-
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
160
|
-
return bytes;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** A subscription's register body, or `null` when the browser handed back an incomplete
|
|
164
|
-
* one. */
|
|
165
|
-
function inputOf(subscription: PushSubscription): PushSubscriptionInput | null {
|
|
166
|
-
const json = subscription.toJSON();
|
|
167
|
-
const p256dh = json.keys?.p256dh;
|
|
168
|
-
const auth = json.keys?.auth;
|
|
169
|
-
if (!json.endpoint || !p256dh || !auth) return null;
|
|
170
|
-
return { endpoint: json.endpoint, keys: { p256dh, auth }, user_agent: navigator.userAgent || null };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
153
|
const defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;
|
|
174
154
|
|
|
175
155
|
/**
|
|
@@ -289,11 +269,18 @@ export function usePushDevice(options: UsePushDeviceOptions): PushDevice {
|
|
|
289
269
|
const heard = () => {
|
|
290
270
|
if (!announcing.current) void sync();
|
|
291
271
|
};
|
|
272
|
+
// The worker's word after it re-registered a rotated subscription (0.46).
|
|
273
|
+
const fromWorker = (event: MessageEvent) => {
|
|
274
|
+
if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();
|
|
275
|
+
};
|
|
276
|
+
const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;
|
|
292
277
|
void sync();
|
|
293
278
|
window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
|
|
279
|
+
worker?.addEventListener?.("message", fromWorker);
|
|
294
280
|
return () => {
|
|
295
281
|
cancelled = true;
|
|
296
282
|
window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);
|
|
283
|
+
worker?.removeEventListener?.("message", fromWorker);
|
|
297
284
|
};
|
|
298
285
|
}, [supported]);
|
|
299
286
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isTransientRefreshFailure } from "../lib/session-refresh";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The leaf utilities of local-first (docs/local-first-harmonization.md §2.3, 0.46): what a
|
|
5
|
+
* page and a sync engine both need, with no store behind them. One module that both
|
|
6
|
+
* `@eifi1/ui-kit/offline` and `@eifi1/ui-kit/sync` export, so `/sync` never loads
|
|
7
|
+
* `/offline`'s TanStack and idb-keyval peers, and `/offline` never loads Dexie. It imports
|
|
8
|
+
* no package at all (packaging-contract.test.ts holds that).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An `updated_at` that wins Last-Write-Wins over `previous`: now, or one millisecond past
|
|
13
|
+
* `previous` when that is not behind this clock (keksdose's sync-client.ts:639-643).
|
|
14
|
+
*
|
|
15
|
+
* `/sync` keeps the server's row when it is STRICTLY newer, comparing this device's clock
|
|
16
|
+
* with the server's. A row the server just wrote carries the server's time, so an edit
|
|
17
|
+
* stamped by a clock a second slow would lose and come straight back. The millisecond is
|
|
18
|
+
* enough although the server keeps microseconds: `Date.parse` truncates them.
|
|
19
|
+
*/
|
|
20
|
+
export function stampAfter(previous: unknown): string {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const prior = typeof previous === "string" ? Date.parse(previous) : Number.NaN;
|
|
23
|
+
return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The connection, not the server, failed: the request had no answer (an axios-like error
|
|
28
|
+
* without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as
|
|
29
|
+
* passing because the server was out of reach ({@link isTransientRefreshFailure}). A real
|
|
30
|
+
* HTTP answer — a 4xx or a 5xx — is never offline: the caller must see it.
|
|
31
|
+
*/
|
|
32
|
+
export function isOfflineError(err: unknown): boolean {
|
|
33
|
+
if (isTransientRefreshFailure(err)) return true;
|
|
34
|
+
if (typeof err !== "object" || err === null) return false;
|
|
35
|
+
const bag = err as { isAxiosError?: unknown; response?: unknown };
|
|
36
|
+
if (bag.isAxiosError === true || "response" in bag) return bag.response == null;
|
|
37
|
+
return err instanceof TypeError;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read from the server, and from the device's copy only when the connection failed
|
|
42
|
+
* ({@link isOfflineError}); a 4xx or 5xx still throws. `onOnline` gets the server's answer
|
|
43
|
+
* before it is returned (to refresh the copy, say); a throw from it counts as a failure of
|
|
44
|
+
* the online path. The plain form of keksdose's net.ts:58, without its sealed-household
|
|
45
|
+
* and cold-start branches (those stay keksdose's).
|
|
46
|
+
*/
|
|
47
|
+
export async function readLocalFirst<T>(
|
|
48
|
+
online: () => Promise<T>,
|
|
49
|
+
offline: () => Promise<T>,
|
|
50
|
+
onOnline?: (result: T) => void | Promise<void>,
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
try {
|
|
53
|
+
const result = await online();
|
|
54
|
+
await onOnline?.(result);
|
|
55
|
+
return result;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (!isOfflineError(err)) throw err;
|
|
58
|
+
return offline();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write through the server first (`rest`), and queue the change on the device (`queue`)
|
|
64
|
+
* only when the connection failed; any other failure throws, nothing queued. Online writes
|
|
65
|
+
* stay server-stamped (D2). A create mints its id BEFORE calling this and sends it on both
|
|
66
|
+
* paths, so a REST create that timed out after the server applied it is not created twice
|
|
67
|
+
* (D16).
|
|
68
|
+
*/
|
|
69
|
+
export async function writeLocalFirst<T>(rest: () => Promise<T>, queue: () => Promise<T>): Promise<T> {
|
|
70
|
+
try {
|
|
71
|
+
return await rest();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (!isOfflineError(err)) throw err;
|
|
74
|
+
return queue();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -71,6 +71,14 @@ export interface QueryPersistenceOptions {
|
|
|
71
71
|
storage?: QueryPersistenceStorage;
|
|
72
72
|
/** Default 1000 ms, as keksdose. */
|
|
73
73
|
throttleTime?: number;
|
|
74
|
+
/**
|
|
75
|
+
* 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`
|
|
76
|
+
* (keksdose: an offline write waits in TanStack's queue and is sent after a reload).
|
|
77
|
+
* `false` keeps every mutation off the disk, and drops any an older build wrote on
|
|
78
|
+
* restore — for an app that queues offline writes in its own outbox (kastlan's Dexie
|
|
79
|
+
* outbox), instead of overriding `dehydrateOptions` by hand.
|
|
80
|
+
*/
|
|
81
|
+
persistMutations?: boolean;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/**
|
|
@@ -112,6 +120,50 @@ const idbStorage: QueryPersistenceStorage = {
|
|
|
112
120
|
keys: () => keys(),
|
|
113
121
|
};
|
|
114
122
|
|
|
123
|
+
export interface RemoveStaleQueryCopiesOptions {
|
|
124
|
+
/** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */
|
|
125
|
+
storage?: QueryPersistenceStorage;
|
|
126
|
+
/** One key to spare whatever its age — the signed-in account's own
|
|
127
|
+
* (`QueryPersistence.removeStale` passes its key). */
|
|
128
|
+
keep?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)
|
|
133
|
+
* older than `maxAge`, with no persistence at hand — for the sign-in page, where no
|
|
134
|
+
* account is known. A session that ended with no user known (a refresh token refused at
|
|
135
|
+
* boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without
|
|
136
|
+
* waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh
|
|
137
|
+
* caches stay; an unreadable one is stale. Never throws.
|
|
138
|
+
* {@link QueryPersistence.removeStale} is this with `keep` = its own key.
|
|
139
|
+
*/
|
|
140
|
+
export async function removeStaleQueryCopies(
|
|
141
|
+
app: string,
|
|
142
|
+
maxAge: number,
|
|
143
|
+
{ storage = idbStorage, keep }: RemoveStaleQueryCopiesOptions = {},
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const prefix = `${app}-query-cache-`;
|
|
146
|
+
try {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
for (const k of await storage.keys()) {
|
|
149
|
+
if (typeof k !== "string" || !k.startsWith(prefix) || k === keep) continue;
|
|
150
|
+
let timestamp = 0;
|
|
151
|
+
try {
|
|
152
|
+
const raw = await storage.getItem(k);
|
|
153
|
+
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
|
154
|
+
if (parsed && typeof parsed === "object" && typeof (parsed as PersistedClient).timestamp === "number") {
|
|
155
|
+
timestamp = (parsed as PersistedClient).timestamp;
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* unreadable: stale */
|
|
159
|
+
}
|
|
160
|
+
if (now - timestamp > maxAge) await storage.removeItem(k);
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* best-effort */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
115
167
|
/**
|
|
116
168
|
* True when `data` still means the same thing after JSON.stringify and JSON.parse —
|
|
117
169
|
* keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a
|
|
@@ -162,6 +214,7 @@ export function createQueryPersistence({
|
|
|
162
214
|
maxAge,
|
|
163
215
|
storage = idbStorage,
|
|
164
216
|
throttleTime = 1000,
|
|
217
|
+
persistMutations = true,
|
|
165
218
|
}: QueryPersistenceOptions): QueryPersistence {
|
|
166
219
|
const prefix = `${app}-query-cache-`;
|
|
167
220
|
const key = `${prefix}${user}${company === undefined || company === null || company === "" ? "" : `-${company}`}`;
|
|
@@ -204,9 +257,9 @@ export function createQueryPersistence({
|
|
|
204
257
|
};
|
|
205
258
|
|
|
206
259
|
/** The write side for mutations: TanStack's default (paused ones), minus any whose
|
|
207
|
-
* variables carry a scrubbed field (S6)
|
|
260
|
+
* variables carry a scrubbed field (S6); none at all with `persistMutations: false`. */
|
|
208
261
|
const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>
|
|
209
|
-
mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
|
|
262
|
+
persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
|
|
210
263
|
|
|
211
264
|
const withScrubbedData = (entry: unknown): unknown => {
|
|
212
265
|
const query = entry as { state?: { data?: unknown } };
|
|
@@ -239,7 +292,7 @@ export function createQueryPersistence({
|
|
|
239
292
|
timestamp,
|
|
240
293
|
buster: stored,
|
|
241
294
|
clientState: {
|
|
242
|
-
mutations: Array.isArray(clientState.mutations)
|
|
295
|
+
mutations: persistMutations && Array.isArray(clientState.mutations)
|
|
243
296
|
? clientState.mutations.filter(
|
|
244
297
|
(m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),
|
|
245
298
|
)
|
|
@@ -291,27 +344,7 @@ export function createQueryPersistence({
|
|
|
291
344
|
}
|
|
292
345
|
};
|
|
293
346
|
|
|
294
|
-
const removeStale =
|
|
295
|
-
try {
|
|
296
|
-
const now = Date.now();
|
|
297
|
-
for (const k of await storage.keys()) {
|
|
298
|
-
if (typeof k !== "string" || !k.startsWith(prefix) || k === key) continue;
|
|
299
|
-
let timestamp = 0;
|
|
300
|
-
try {
|
|
301
|
-
const raw = await storage.getItem(k);
|
|
302
|
-
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
|
303
|
-
if (parsed && typeof parsed === "object" && typeof (parsed as PersistedClient).timestamp === "number") {
|
|
304
|
-
timestamp = (parsed as PersistedClient).timestamp;
|
|
305
|
-
}
|
|
306
|
-
} catch {
|
|
307
|
-
/* unreadable: stale */
|
|
308
|
-
}
|
|
309
|
-
if (now - timestamp > maxAge) await storage.removeItem(k);
|
|
310
|
-
}
|
|
311
|
-
} catch {
|
|
312
|
-
/* best-effort */
|
|
313
|
-
}
|
|
314
|
-
};
|
|
347
|
+
const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });
|
|
315
348
|
|
|
316
349
|
return {
|
|
317
350
|
key,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* vite-plugin-pwa's `registerSW` from `virtual:pwa-register` (`registerType: "prompt"`) —
|
|
5
|
+
* the app passes it in, since a virtual module is the app's build's, not the kit's. It
|
|
6
|
+
* answers the `updateSW(reloadPage)` function.
|
|
7
|
+
*/
|
|
8
|
+
export type RegisterServiceWorker = (options: {
|
|
9
|
+
onNeedRefresh?: () => void;
|
|
10
|
+
onOfflineReady?: () => void;
|
|
11
|
+
}) => (reloadPage?: boolean) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
/** What {@link createServiceWorkerUpdates} answers: one store per app, a module constant. */
|
|
14
|
+
export interface ServiceWorkerUpdates {
|
|
15
|
+
/** Registers the service worker — once, however often it is called. Call it at start,
|
|
16
|
+
* before the router renders, so the sign-in pages get the offline shell too. */
|
|
17
|
+
start: () => void;
|
|
18
|
+
/** Whether a new version waits. */
|
|
19
|
+
needRefresh: () => boolean;
|
|
20
|
+
/** For `useSyncExternalStore`. */
|
|
21
|
+
subscribe: (listener: () => void) => () => void;
|
|
22
|
+
/** Activates the waiting version and reloads (`updateSW(true)`). Before `start`, nothing. */
|
|
23
|
+
reload: () => void;
|
|
24
|
+
/** The person closed the prompt: the version keeps waiting, the prompt stays down. */
|
|
25
|
+
dismiss: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 0.46 (kastlan K3, docs/local-first-harmonization.md §1.5 D8): the service worker
|
|
30
|
+
* registered ONCE AT START, and its "a new version waits" state as a small store the
|
|
31
|
+
* prompt reads wherever it lives. vite-plugin-pwa's `useRegisterSW` registers when the
|
|
32
|
+
* component that calls it mounts: an app whose {@link PwaUpdatePrompt} sits in its
|
|
33
|
+
* signed-in shell (it reads the tour state, the route) would register only after sign-in,
|
|
34
|
+
* and the sign-in pages would lose the offline shell on a first visit.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* // shared/pwa/sw-updates.ts
|
|
38
|
+
* import { registerSW } from "virtual:pwa-register";
|
|
39
|
+
* export const swUpdates = createServiceWorkerUpdates(registerSW);
|
|
40
|
+
* // main.tsx, before render: swUpdates.start();
|
|
41
|
+
* // the shell: <PwaUpdatePrompt {...useServiceWorkerUpdate(swUpdates)} hold={tourRunning} />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function createServiceWorkerUpdates(register: RegisterServiceWorker): ServiceWorkerUpdates {
|
|
45
|
+
let waiting = false;
|
|
46
|
+
let update: ((reloadPage?: boolean) => Promise<void>) | null = null;
|
|
47
|
+
const listeners = new Set<() => void>();
|
|
48
|
+
const emit = () => listeners.forEach((listener) => listener());
|
|
49
|
+
const set = (value: boolean) => {
|
|
50
|
+
if (waiting === value) return;
|
|
51
|
+
waiting = value;
|
|
52
|
+
emit();
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
start: () => {
|
|
56
|
+
if (update) return;
|
|
57
|
+
update = register({ onNeedRefresh: () => set(true) });
|
|
58
|
+
},
|
|
59
|
+
needRefresh: () => waiting,
|
|
60
|
+
subscribe: (listener) => {
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
reload: () => {
|
|
67
|
+
void update?.(true);
|
|
68
|
+
},
|
|
69
|
+
dismiss: () => set(false),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** {@link PwaUpdatePrompt}'s props from the store: `needRefresh`, `onReload`, `onDismiss`. */
|
|
74
|
+
export function useServiceWorkerUpdate(updates: ServiceWorkerUpdates): {
|
|
75
|
+
needRefresh: boolean;
|
|
76
|
+
onReload: () => void;
|
|
77
|
+
onDismiss: () => void;
|
|
78
|
+
} {
|
|
79
|
+
const needRefresh = useSyncExternalStore(updates.subscribe, updates.needRefresh, () => false);
|
|
80
|
+
return { needRefresh, onReload: updates.reload, onDismiss: updates.dismiss };
|
|
81
|
+
}
|
package/src/offline.ts
CHANGED
|
@@ -6,17 +6,21 @@
|
|
|
6
6
|
// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and
|
|
7
7
|
// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which
|
|
8
8
|
// would make them everyone's install; the packaging contract test holds that line.
|
|
9
|
-
export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe } from "./offline/query-persistence";
|
|
9
|
+
export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from "./offline/query-persistence";
|
|
10
10
|
export type {
|
|
11
11
|
QueryPersistence,
|
|
12
12
|
QueryPersistenceOptions,
|
|
13
13
|
QueryPersistenceStorage,
|
|
14
|
+
RemoveStaleQueryCopiesOptions,
|
|
14
15
|
ScrubField,
|
|
15
16
|
} from "./offline/query-persistence";
|
|
16
17
|
export { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "./offline/sign-out-pending-dialog";
|
|
17
18
|
export type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from "./offline/sign-out-pending-dialog";
|
|
18
19
|
export { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./offline/pwa-update-prompt";
|
|
19
20
|
export type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from "./offline/pwa-update-prompt";
|
|
21
|
+
// 0.46 (kastlan K3): the service worker registered once at start, its update state as a store.
|
|
22
|
+
export { createServiceWorkerUpdates, useServiceWorkerUpdate } from "./offline/service-worker-updates";
|
|
23
|
+
export type { RegisterServiceWorker, ServiceWorkerUpdates } from "./offline/service-worker-updates";
|
|
20
24
|
export {
|
|
21
25
|
SharedComputerChoice,
|
|
22
26
|
DEFAULT_SHARED_COMPUTER_LABELS,
|
|
@@ -24,3 +28,5 @@ export {
|
|
|
24
28
|
setSharedComputer,
|
|
25
29
|
} from "./offline/shared-computer";
|
|
26
30
|
export type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from "./offline/shared-computer";
|
|
31
|
+
// 0.46 (§2.3): the leaf utilities, shared with `@eifi1/ui-kit/sync` from one module that imports no peer.
|
|
32
|
+
export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// `@eifi1/ui-kit/push-worker` — the service worker's half of Web Push (0.46,
|
|
2
|
+
// docs/notifications-harmonization.md §10.6 rule 5): re-registering a subscription the browser
|
|
3
|
+
// rotated. A worker bundle imports from here, never from the main barrel, which reaches React
|
|
4
|
+
// and the DOM components:
|
|
5
|
+
//
|
|
6
|
+
// // sw.ts
|
|
7
|
+
// import { handlePushSubscriptionChange } from "@eifi1/ui-kit/push-worker";
|
|
8
|
+
//
|
|
9
|
+
// Worker-safe: this entry reaches no package and no module but src/notifications/push-worker.ts
|
|
10
|
+
// (packaging-contract.test.ts, scripts/check-module-graph.mjs, scripts/verify-package.mjs).
|
|
11
|
+
// The main barrel keeps re-exporting the same names, so an existing import keeps working.
|
|
12
|
+
export {
|
|
13
|
+
PUSH_SUBSCRIPTION_CHANGED,
|
|
14
|
+
handlePushSubscriptionChange,
|
|
15
|
+
pushSubscriptionInput,
|
|
16
|
+
resubscribeAfterChange,
|
|
17
|
+
} from "./notifications/push-worker";
|
|
18
|
+
export type {
|
|
19
|
+
PushReplaceInput,
|
|
20
|
+
PushResubscribeOutcome,
|
|
21
|
+
PushSubscriptionChangeEventLike,
|
|
22
|
+
PushSubscriptionChangeOptions,
|
|
23
|
+
PushSubscriptionInput,
|
|
24
|
+
PushWorkerScope,
|
|
25
|
+
} from "./notifications/push-worker";
|