@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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
import { Bell, CheckCheck, Trash2 } from "lucide-react";
|
|
4
|
+
import type { LucideIcon } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
7
|
+
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
8
|
+
import { cn } from "../lib/cn";
|
|
9
|
+
import { useKitFormat } from "../lib/format";
|
|
10
|
+
import { Button } from "../components/button";
|
|
11
|
+
import { Card } from "../components/card";
|
|
12
|
+
import { EmptyState } from "../components/empty-state";
|
|
13
|
+
import { IconButton } from "../components/icon-button";
|
|
14
|
+
import { List, ListItem } from "../components/list";
|
|
15
|
+
import { Skeleton } from "../components/skeleton";
|
|
16
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
17
|
+
import { SettingsCardTitle } from "../settings/settings-heading";
|
|
18
|
+
import { settle, useMounted } from "../account/account-parts";
|
|
19
|
+
import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
|
|
20
|
+
import type { NotificationsLabels } from "./notifications-labels";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One inbox row as `GET /notifications/inbox` answers it (server-kit `InboxItem`, §3.7):
|
|
24
|
+
* the title and body already in the recipient's language.
|
|
25
|
+
*/
|
|
26
|
+
export interface NotificationInboxItem<Id extends string | number = string | number> {
|
|
27
|
+
id: Id;
|
|
28
|
+
/** The type id, for the row's icon. */
|
|
29
|
+
notification_type: string;
|
|
30
|
+
title: string;
|
|
31
|
+
body: string;
|
|
32
|
+
/** A path in the app, an absolute URL, or `null` (nothing to open). */
|
|
33
|
+
url: string | null;
|
|
34
|
+
/** ISO date-time. */
|
|
35
|
+
created_at: string;
|
|
36
|
+
/** ISO date-time, or `null` while unread. */
|
|
37
|
+
read_at: string | null;
|
|
38
|
+
/** 0.46 (server-kit 0.16 `InboxItem.company`): the company the row is about, named on
|
|
39
|
+
* the row — kastlan's inbox holds every company's rows. */
|
|
40
|
+
company?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface NotificationInboxProps<Id extends string | number = string | number>
|
|
44
|
+
extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
|
|
45
|
+
/** The rows, newest first; `undefined` while the first page loads. */
|
|
46
|
+
items: readonly NotificationInboxItem<Id>[] | undefined;
|
|
47
|
+
/** Every unread row of the account (`unread_count`), not only this page's. */
|
|
48
|
+
unreadCount: number;
|
|
49
|
+
loading?: boolean;
|
|
50
|
+
/** `POST /notifications/inbox/read {ids}` — these rows, or `null` for all. */
|
|
51
|
+
onMarkRead: (ids: Id[] | null) => Promise<unknown> | void;
|
|
52
|
+
/** `DELETE /notifications/inbox`. Asked first, in the header. */
|
|
53
|
+
onClear: () => Promise<unknown> | void;
|
|
54
|
+
/**
|
|
55
|
+
* Open a row: the kit marks it read first, then calls this, and the app routes to
|
|
56
|
+
* `item.url` (kastlan switches the acting company first). Every row is a button then.
|
|
57
|
+
* Left out, a row with a `url` is a real link — an in-app path through the provider's
|
|
58
|
+
* router link, an absolute URL in a new tab — that marks itself read on a click or a
|
|
59
|
+
* middle click (keksdose #451), and a row without one marks itself read.
|
|
60
|
+
*/
|
|
61
|
+
onOpen?: (item: NotificationInboxItem<Id>) => void;
|
|
62
|
+
/** The row's glyph for a type — the app's map (keksdose's `TYPE_ICONS`). `undefined`,
|
|
63
|
+
* or a type it does not know (a retired one), is the `Bell`. */
|
|
64
|
+
icon?: (type: string) => LucideIcon | undefined;
|
|
65
|
+
/** More rows than shown: a "Show more" row calls `onLoadMore` (the API pages by
|
|
66
|
+
* `offset`). */
|
|
67
|
+
hasMore?: boolean;
|
|
68
|
+
onLoadMore?: () => void;
|
|
69
|
+
/** The next page is loading. */
|
|
70
|
+
loadingMore?: boolean;
|
|
71
|
+
/** A row's time. Default: the provider's locale and `timeZone`, date and time. */
|
|
72
|
+
formatDate?: (iso: string) => string;
|
|
73
|
+
/** The heading. Default `labels.inboxTitle` ("Inbox"). */
|
|
74
|
+
title?: ReactNode;
|
|
75
|
+
/** The app's own words for a failed request, or `undefined` for the kit's `failed`. */
|
|
76
|
+
describeError?: (error: unknown) => ReactNode | undefined;
|
|
77
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The in-app inbox (0.46, docs/notifications-harmonization.md §10.1), keksdose's list
|
|
82
|
+
* lifted: newest first, an icon per type (`Bell` for one the app does not map), the
|
|
83
|
+
* title and two lines of body, the time, and — kastlan — the company the row is about.
|
|
84
|
+
* Unread rows are bold with a dot. Props only: the app keeps the data (poll the badge
|
|
85
|
+
* every {@link INBOX_BADGE_INTERVAL}, invalidate on its worker's push message).
|
|
86
|
+
*
|
|
87
|
+
* - **Opening a row marks it read** (see `onOpen`).
|
|
88
|
+
* - **"Mark all as read"** while anything is unread; **"Clear all"** asks first, in the
|
|
89
|
+
* header — no `ConfirmProvider` needed — with focus on Cancel.
|
|
90
|
+
* - **"Show more"** with `hasMore`; the list scrolls inside a box of at most
|
|
91
|
+
* `min(24rem, 70vh)`, so it never pushes the settings below it off the page.
|
|
92
|
+
*
|
|
93
|
+
* Under a write lock the header's actions are `commit` controls exempt from billing.
|
|
94
|
+
*/
|
|
95
|
+
export function NotificationInbox<Id extends string | number = string | number>({
|
|
96
|
+
items,
|
|
97
|
+
unreadCount,
|
|
98
|
+
loading,
|
|
99
|
+
onMarkRead,
|
|
100
|
+
onClear,
|
|
101
|
+
onOpen,
|
|
102
|
+
icon,
|
|
103
|
+
hasMore = false,
|
|
104
|
+
onLoadMore,
|
|
105
|
+
loadingMore = false,
|
|
106
|
+
formatDate: formatDateProp,
|
|
107
|
+
title,
|
|
108
|
+
describeError,
|
|
109
|
+
labels: labelsProp,
|
|
110
|
+
className,
|
|
111
|
+
...rest
|
|
112
|
+
}: NotificationInboxProps<Id>) {
|
|
113
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
114
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
115
|
+
const { formatDate } = useKitFormat();
|
|
116
|
+
const [asking, setAsking] = useState(false);
|
|
117
|
+
const [busy, setBusy] = useState<"read" | "clear" | null>(null);
|
|
118
|
+
const [failure, setFailure] = useState<ReactNode>(null);
|
|
119
|
+
const mounted = useMounted();
|
|
120
|
+
const headingId = useId();
|
|
121
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
122
|
+
const clearRef = useRef<HTMLButtonElement>(null);
|
|
123
|
+
const [returning, setReturning] = useState(false);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (asking) cancelRef.current?.focus();
|
|
126
|
+
else if (returning) clearRef.current?.focus();
|
|
127
|
+
}, [asking, returning]);
|
|
128
|
+
|
|
129
|
+
const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "dateTime"));
|
|
130
|
+
const iconOf = (type: string): LucideIcon => icon?.(type) ?? Bell;
|
|
131
|
+
|
|
132
|
+
const run = (which: "read" | "clear", call: () => unknown, after?: () => void) => {
|
|
133
|
+
setBusy(which);
|
|
134
|
+
setFailure(null);
|
|
135
|
+
settle(call).then(
|
|
136
|
+
() => {
|
|
137
|
+
if (!mounted.current) return;
|
|
138
|
+
setBusy(null);
|
|
139
|
+
after?.();
|
|
140
|
+
},
|
|
141
|
+
(error: unknown) => {
|
|
142
|
+
if (!mounted.current) return;
|
|
143
|
+
setBusy(null);
|
|
144
|
+
setFailure(describeFailure(describeError?.(error), labels.failed));
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Mark one row read; a failure is said under the list, the row stays as it is. */
|
|
150
|
+
const markOne = (item: NotificationInboxItem<Id>) => {
|
|
151
|
+
if (item.read_at !== null) return;
|
|
152
|
+
settle(() => onMarkRead([item.id])).catch((error: unknown) => {
|
|
153
|
+
if (mounted.current) setFailure(describeFailure(describeError?.(error), labels.failed));
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const leave = () => {
|
|
158
|
+
setReturning(true);
|
|
159
|
+
setAsking(false);
|
|
160
|
+
};
|
|
161
|
+
const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
162
|
+
if (event.key === "Escape") {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
event.stopPropagation();
|
|
165
|
+
leave();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const count = items?.length ?? 0;
|
|
170
|
+
|
|
171
|
+
const header = asking ? (
|
|
172
|
+
<div className="space-y-2 border-b border-subtle px-3 py-2" data-inbox-confirm="">
|
|
173
|
+
<p role="alert" className="text-sm">
|
|
174
|
+
{labels.clearPrompt}
|
|
175
|
+
</p>
|
|
176
|
+
<div className="flex flex-wrap gap-2">
|
|
177
|
+
<Button
|
|
178
|
+
type="button"
|
|
179
|
+
size="sm"
|
|
180
|
+
variant="danger"
|
|
181
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
182
|
+
pending={busy === "clear"}
|
|
183
|
+
onClick={() => run("clear", onClear, () => setAsking(false))}
|
|
184
|
+
onKeyDown={escapeLeaves}
|
|
185
|
+
>
|
|
186
|
+
{labels.clearAll}
|
|
187
|
+
</Button>
|
|
188
|
+
<Button ref={cancelRef} type="button" size="sm" variant="ghost" onClick={leave} onKeyDown={escapeLeaves}>
|
|
189
|
+
{common.cancel}
|
|
190
|
+
</Button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
) : (
|
|
194
|
+
<div className="flex items-center justify-between gap-2 border-b border-subtle px-3 py-2">
|
|
195
|
+
<SettingsCardTitle id={headingId}>{title ?? labels.inboxTitle}</SettingsCardTitle>
|
|
196
|
+
<div className="flex items-center gap-0.5">
|
|
197
|
+
<IconButton
|
|
198
|
+
type="button"
|
|
199
|
+
size="xs"
|
|
200
|
+
tone="muted"
|
|
201
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
202
|
+
onClick={() => run("read", () => onMarkRead(null))}
|
|
203
|
+
disabled={unreadCount === 0 || busy !== null}
|
|
204
|
+
label={labels.markAllRead}
|
|
205
|
+
>
|
|
206
|
+
<CheckCheck />
|
|
207
|
+
</IconButton>
|
|
208
|
+
<IconButton
|
|
209
|
+
ref={clearRef}
|
|
210
|
+
type="button"
|
|
211
|
+
size="xs"
|
|
212
|
+
tone="danger"
|
|
213
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
214
|
+
onClick={() => {
|
|
215
|
+
setReturning(false);
|
|
216
|
+
setAsking(true);
|
|
217
|
+
}}
|
|
218
|
+
disabled={count === 0 || busy !== null}
|
|
219
|
+
label={labels.clearAll}
|
|
220
|
+
>
|
|
221
|
+
<Trash2 />
|
|
222
|
+
</IconButton>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
let body: ReactNode;
|
|
228
|
+
if (loading || items === undefined) {
|
|
229
|
+
body = <Skeleton lines={3} label={labels.inboxLoading} className="p-3" />;
|
|
230
|
+
} else if (items.length === 0) {
|
|
231
|
+
body = <EmptyState variant="inline" title={labels.inboxEmpty} className="px-3 py-2" />;
|
|
232
|
+
} else {
|
|
233
|
+
body = (
|
|
234
|
+
// Named by the heading — except while "Clear all" asks, when the heading is away.
|
|
235
|
+
<List className="p-1" aria-labelledby={asking ? undefined : headingId} aria-label={asking ? labels.inboxTitle : undefined}>
|
|
236
|
+
{items.map((item) => {
|
|
237
|
+
const row = {
|
|
238
|
+
title: item.title,
|
|
239
|
+
subtitle: item.body,
|
|
240
|
+
subtitleLines: 2 as const,
|
|
241
|
+
overline: item.company ?? undefined,
|
|
242
|
+
meta: timeOf(item.created_at),
|
|
243
|
+
icon: iconOf(item.notification_type),
|
|
244
|
+
unread: item.read_at === null,
|
|
245
|
+
align: "start" as const,
|
|
246
|
+
};
|
|
247
|
+
if (onOpen) {
|
|
248
|
+
return (
|
|
249
|
+
<ListItem
|
|
250
|
+
key={item.id}
|
|
251
|
+
{...row}
|
|
252
|
+
data-inbox-item={String(item.id)}
|
|
253
|
+
onClick={() => {
|
|
254
|
+
markOne(item);
|
|
255
|
+
onOpen(item);
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!item.url) {
|
|
261
|
+
return <ListItem key={item.id} {...row} data-inbox-item={String(item.id)} onClick={() => markOne(item)} />;
|
|
262
|
+
}
|
|
263
|
+
// A real link, so a middle click opens a background tab; a middle click fires no
|
|
264
|
+
// `click`, hence `onAuxClick` (keksdose #451).
|
|
265
|
+
return (
|
|
266
|
+
<ListItem
|
|
267
|
+
key={item.id}
|
|
268
|
+
{...row}
|
|
269
|
+
data-inbox-item={String(item.id)}
|
|
270
|
+
href={item.url}
|
|
271
|
+
external={!item.url.startsWith("/")}
|
|
272
|
+
onClick={() => markOne(item)}
|
|
273
|
+
onAuxClick={(event) => {
|
|
274
|
+
if (event.button === 1) markOne(item);
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
})}
|
|
279
|
+
{hasMore && onLoadMore && (
|
|
280
|
+
<li className="px-2 py-1">
|
|
281
|
+
<Button type="button" size="sm" variant="ghost" pending={loadingMore} onClick={onLoadMore} className="w-full">
|
|
282
|
+
{labels.showMore}
|
|
283
|
+
</Button>
|
|
284
|
+
</li>
|
|
285
|
+
)}
|
|
286
|
+
</List>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<Card {...rest} className={cn("overflow-hidden p-0", className)}>
|
|
292
|
+
{header}
|
|
293
|
+
<div className="max-h-[min(24rem,70vh)] overflow-y-auto overscroll-contain">{body}</div>
|
|
294
|
+
{failure !== null && (
|
|
295
|
+
<div className="px-3 pb-3">
|
|
296
|
+
<FailureLine>{failure}</FailureLine>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</Card>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** How often an app polls the unread count for the badge (keksdose's 60 s, `limit:1`). */
|
|
304
|
+
export const INBOX_BADGE_INTERVAL = 60_000;
|
|
@@ -10,6 +10,7 @@ import { Skeleton } from "../components/skeleton";
|
|
|
10
10
|
import { Switch } from "../components/switch";
|
|
11
11
|
import { SectionLabel } from "../components/text";
|
|
12
12
|
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
13
|
+
import { usePhoneLayout } from "../hooks/use-breakpoint";
|
|
13
14
|
import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
|
|
14
15
|
import { SettingsCardTitle } from "../settings/settings-heading";
|
|
15
16
|
import { settle, useMounted } from "../account/account-parts";
|
|
@@ -18,6 +19,7 @@ import type { WriteOverlay } from "./notification-parts";
|
|
|
18
19
|
import type { NotificationsLabels } from "./notifications-labels";
|
|
19
20
|
import type {
|
|
20
21
|
DigestCadence,
|
|
22
|
+
NotificationChannel,
|
|
21
23
|
NotificationSettingsPatch,
|
|
22
24
|
NotificationSwitches,
|
|
23
25
|
NotificationTypeEntry,
|
|
@@ -73,15 +75,33 @@ interface Row<Id extends string> {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/** A switch's key in the write overlay. */
|
|
76
|
-
const switchKey = (id: string, channel:
|
|
78
|
+
const switchKey = (id: string, channel: NotificationChannel) => `${id}.${channel}`;
|
|
77
79
|
const CADENCE_KEY = "digest_cadence";
|
|
78
80
|
|
|
81
|
+
/** The grid of a several-channel row on a wide screen: the type's words, then one narrow
|
|
82
|
+
* column per channel drawn. Literal classes, so Tailwind's scan finds them. */
|
|
83
|
+
const COLUMN_GRID: Record<number, string> = {
|
|
84
|
+
1: "grid-cols-[minmax(0,1fr)_4.5rem]",
|
|
85
|
+
2: "grid-cols-[minmax(0,1fr)_repeat(2,4.5rem)]",
|
|
86
|
+
3: "grid-cols-[minmax(0,1fr)_repeat(3,4.5rem)]",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The channels in the card's column order (§10.2). */
|
|
90
|
+
const CHANNELS: readonly NotificationChannel[] = ["app", "push", "email"];
|
|
91
|
+
|
|
92
|
+
/** The channels a type offers: every one the server did not answer `null` (or leave out,
|
|
93
|
+
* as `push` before server-kit 0.16). */
|
|
94
|
+
const channelsOf = (switches: NotificationSwitches): NotificationChannel[] =>
|
|
95
|
+
CHANNELS.filter((channel) => switches[channel] !== null && switches[channel] !== undefined);
|
|
96
|
+
|
|
79
97
|
/**
|
|
80
98
|
* What the person hears about, and how (docs/notifications-harmonization.md §2.1–§2.3,
|
|
81
|
-
* §4.2): one row per type the SERVER answered, the app's words on it, a switch
|
|
82
|
-
* the
|
|
83
|
-
*
|
|
84
|
-
*
|
|
99
|
+
* §4.2, §10.2): one row per type the SERVER answered, the app's words on it, and a switch
|
|
100
|
+
* per channel the type offers — "In the app", "Push", "E-mail". On a wide screen they are
|
|
101
|
+
* narrow columns under those headings; on a phone they stack under the type's label. A
|
|
102
|
+
* channel the server answers `null` is not drawn (kastlan and KS offer no push; KS no
|
|
103
|
+
* "in the app"), and a row with one channel keeps a single switch named by the type
|
|
104
|
+
* (Kurvenschmiede's e-mail-only rows).
|
|
85
105
|
*
|
|
86
106
|
* **The server's list is the only list.** The card keeps no defaults and no list of
|
|
87
107
|
* types: it draws what `value` holds, in the order of `groups`, else of `types`. A type
|
|
@@ -140,23 +160,24 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
140
160
|
}
|
|
141
161
|
}
|
|
142
162
|
|
|
163
|
+
const phone = usePhoneLayout();
|
|
143
164
|
const rowOf = (id: Id): Row<Id> | null => {
|
|
144
165
|
const switches = value?.[id];
|
|
145
166
|
const entry = entries.get(id);
|
|
146
|
-
if (!switches || !entry || (switches.
|
|
167
|
+
if (!switches || !entry || channelsOf(switches).length === 0) return null;
|
|
147
168
|
return { entry, switches };
|
|
148
169
|
};
|
|
149
170
|
const rowsOf = (ids: readonly Id[]) => ids.map(rowOf).filter((row): row is Row<Id> => row !== null);
|
|
150
171
|
|
|
151
|
-
const shown = (id: string, channel:
|
|
172
|
+
const shown = (id: string, channel: NotificationChannel, server: boolean): boolean => {
|
|
152
173
|
const key = switchKey(id, channel);
|
|
153
174
|
return overlay.get(key) ?? server;
|
|
154
175
|
};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
176
|
+
const channelOn = ({ entry, switches }: Row<Id>, channel: NotificationChannel) =>
|
|
177
|
+
shown(entry.id, channel, switches[channel] === true);
|
|
178
|
+
/** The type reaches the person at all: some channel is on (the cadence and the app's
|
|
179
|
+
* extra show then). */
|
|
180
|
+
const rowOn = (row: Row<Id>) => channelsOf(row.switches).some((channel) => channelOn(row, channel));
|
|
160
181
|
|
|
161
182
|
const save = <V,>(key: string, patch: NotificationSettingsPatch<Id>, which: WriteOverlay<V>, next: V) => {
|
|
162
183
|
if (which.pending(key)) return;
|
|
@@ -174,7 +195,7 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
174
195
|
);
|
|
175
196
|
};
|
|
176
197
|
|
|
177
|
-
const flip = (id: Id, channel:
|
|
198
|
+
const flip = (id: Id, channel: NotificationChannel, on: boolean) =>
|
|
178
199
|
save(
|
|
179
200
|
switchKey(id, channel),
|
|
180
201
|
{ types: { [id]: { [channel]: on } } as NotificationSettingsPatch<Id>["types"] },
|
|
@@ -190,11 +211,33 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
190
211
|
</span>
|
|
191
212
|
) : null;
|
|
192
213
|
|
|
214
|
+
const channelLabel: Record<NotificationChannel, string> = {
|
|
215
|
+
app: labels.channelApp,
|
|
216
|
+
push: labels.channelPush,
|
|
217
|
+
email: labels.channelEmail,
|
|
218
|
+
};
|
|
219
|
+
/** The columns on a wide screen: every channel some several-channel row offers. */
|
|
220
|
+
const drawnRows = value && !loading ? rowsOf(groups ? groups.flatMap((group) => group.types) : types.map((entry) => entry.id)) : [];
|
|
221
|
+
const multi = drawnRows.filter((row) => channelsOf(row.switches).length > 1);
|
|
222
|
+
const columns = CHANNELS.filter((channel) => multi.some((row) => channelsOf(row.switches).includes(channel)));
|
|
223
|
+
const columnHeadings =
|
|
224
|
+
!phone && columns.length > 0 ? (
|
|
225
|
+
// The switches carry the channel's name themselves; the headings are for the eye.
|
|
226
|
+
<div aria-hidden className={cn("grid gap-x-2", COLUMN_GRID[columns.length])} data-channel-headings="">
|
|
227
|
+
<span />
|
|
228
|
+
{columns.map((channel) => (
|
|
229
|
+
<span key={channel} className="text-center text-caption font-medium text-muted">
|
|
230
|
+
{channelLabel[channel]}
|
|
231
|
+
</span>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
) : null;
|
|
235
|
+
|
|
193
236
|
const renderRow = (row: Row<Id>) => {
|
|
194
237
|
const { entry, switches } = row;
|
|
195
238
|
const on = rowOn(row);
|
|
196
|
-
const
|
|
197
|
-
const
|
|
239
|
+
const offered = channelsOf(switches);
|
|
240
|
+
const single = offered.length === 1 ? offered[0] : null;
|
|
198
241
|
const cadenceSelect =
|
|
199
242
|
on && digest?.type === entry.id && cadence ? (
|
|
200
243
|
<Select
|
|
@@ -213,52 +256,99 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
213
256
|
</Select>
|
|
214
257
|
) : null;
|
|
215
258
|
const extra = on ? renderExtra?.(entry.id) : null;
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{
|
|
220
|
-
{
|
|
221
|
-
|
|
222
|
-
) :
|
|
223
|
-
|
|
259
|
+
const below =
|
|
260
|
+
cadenceSelect || hasContent(extra) ? (
|
|
261
|
+
<div className="ps-4 space-y-2">
|
|
262
|
+
{cadenceSelect}
|
|
263
|
+
{extra}
|
|
264
|
+
</div>
|
|
265
|
+
) : null;
|
|
266
|
+
|
|
267
|
+
// One channel: today's single switch, named by the type (KS's e-mail-only rows).
|
|
268
|
+
if (single) {
|
|
269
|
+
const description =
|
|
270
|
+
single === "email" && unverifiedLine ? (
|
|
271
|
+
<>
|
|
272
|
+
{entry.description !== undefined && <span className="block">{entry.description}</span>}
|
|
273
|
+
{unverifiedLine}
|
|
274
|
+
</>
|
|
275
|
+
) : (
|
|
276
|
+
entry.description
|
|
277
|
+
);
|
|
278
|
+
return (
|
|
279
|
+
<div key={entry.id} data-notification-type={entry.id} data-channels={single} className="space-y-2">
|
|
280
|
+
<Switch
|
|
281
|
+
label={entry.label}
|
|
282
|
+
description={description}
|
|
283
|
+
checked={channelOn(row, single)}
|
|
284
|
+
disabled={disabled}
|
|
285
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
286
|
+
onCheckedChange={(next) => flip(entry.id, single, next)}
|
|
287
|
+
/>
|
|
288
|
+
{below}
|
|
289
|
+
</div>
|
|
224
290
|
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Several channels: a group named by its type, a switch per channel — columns on a
|
|
294
|
+
// wide screen (the headings above the list name them), stacked on a phone.
|
|
295
|
+
const labelId = `${groupId}-type-${entry.id}`;
|
|
296
|
+
const unverifiedId = `${groupId}-unverified-${entry.id}`;
|
|
297
|
+
const channelSwitch = (channel: NotificationChannel) => (
|
|
298
|
+
<Switch
|
|
299
|
+
key={channel}
|
|
300
|
+
size="sm"
|
|
301
|
+
label={phone ? channelLabel[channel] : undefined}
|
|
302
|
+
aria-label={phone ? undefined : channelLabel[channel]}
|
|
303
|
+
description={phone && channel === "email" ? (unverifiedLine ?? undefined) : undefined}
|
|
304
|
+
aria-describedby={!phone && channel === "email" && unverifiedLine ? unverifiedId : undefined}
|
|
305
|
+
checked={channelOn(row, channel)}
|
|
306
|
+
disabled={disabled}
|
|
307
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
308
|
+
onCheckedChange={(next) => flip(entry.id, channel, next)}
|
|
309
|
+
data-channel={channel}
|
|
310
|
+
/>
|
|
311
|
+
);
|
|
312
|
+
const text = (
|
|
313
|
+
<div className="min-w-0">
|
|
314
|
+
<div id={labelId} className="text-sm font-medium text-primary">
|
|
315
|
+
{entry.label}
|
|
316
|
+
</div>
|
|
317
|
+
{(entry.description !== undefined || (!phone && offered.includes("email") && unverifiedLine)) && (
|
|
318
|
+
<div className={CARD_DESCRIPTION_CLASS}>
|
|
319
|
+
{entry.description !== undefined && <span className="block">{entry.description}</span>}
|
|
320
|
+
{!phone && offered.includes("email") && <span id={unverifiedId}>{unverifiedLine}</span>}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
225
325
|
return (
|
|
226
326
|
<div
|
|
227
327
|
key={entry.id}
|
|
228
328
|
data-notification-type={entry.id}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
aria-label={both ? entry.label : undefined}
|
|
329
|
+
data-channels={offered.join(" ")}
|
|
330
|
+
role="group"
|
|
331
|
+
aria-labelledby={labelId}
|
|
233
332
|
className="space-y-2"
|
|
234
333
|
>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
disabled={disabled}
|
|
251
|
-
commit={COMMIT_EXCEPT_BILLING}
|
|
252
|
-
onCheckedChange={(next) => flip(entry.id, "email", next)}
|
|
253
|
-
/>
|
|
254
|
-
</div>
|
|
255
|
-
)}
|
|
256
|
-
{(cadenceSelect || hasContent(extra)) && (
|
|
257
|
-
<div className="ps-4 space-y-2">
|
|
258
|
-
{cadenceSelect}
|
|
259
|
-
{extra}
|
|
334
|
+
{phone ? (
|
|
335
|
+
<>
|
|
336
|
+
{text}
|
|
337
|
+
<div className="ps-4 space-y-2" data-stacked="">
|
|
338
|
+
{offered.map(channelSwitch)}
|
|
339
|
+
</div>
|
|
340
|
+
</>
|
|
341
|
+
) : (
|
|
342
|
+
<div className={cn("grid items-start gap-x-2", COLUMN_GRID[columns.length])}>
|
|
343
|
+
{text}
|
|
344
|
+
{columns.map((channel) => (
|
|
345
|
+
<div key={channel} className="flex justify-center pt-0.5">
|
|
346
|
+
{offered.includes(channel) ? channelSwitch(channel) : null}
|
|
347
|
+
</div>
|
|
348
|
+
))}
|
|
260
349
|
</div>
|
|
261
350
|
)}
|
|
351
|
+
{below}
|
|
262
352
|
</div>
|
|
263
353
|
);
|
|
264
354
|
};
|
|
@@ -274,7 +364,9 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
274
364
|
drawn.length === 0 ? (
|
|
275
365
|
<p className={CARD_DESCRIPTION_CLASS}>{labels.typesEmpty}</p>
|
|
276
366
|
) : (
|
|
277
|
-
|
|
367
|
+
<>
|
|
368
|
+
{columnHeadings}
|
|
369
|
+
{drawn.map(({ group, rows }) => {
|
|
278
370
|
const headingId = `${groupId}-${group.id}`;
|
|
279
371
|
return (
|
|
280
372
|
<div key={group.id} role="group" aria-labelledby={headingId} className="space-y-3">
|
|
@@ -284,7 +376,8 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
284
376
|
{rows.map(renderRow)}
|
|
285
377
|
</div>
|
|
286
378
|
);
|
|
287
|
-
})
|
|
379
|
+
})}
|
|
380
|
+
</>
|
|
288
381
|
);
|
|
289
382
|
} else {
|
|
290
383
|
const rows = rowsOf(types.map((entry) => entry.id));
|
|
@@ -292,7 +385,10 @@ export function NotificationTypesSetting<Id extends string = string>({
|
|
|
292
385
|
rows.length === 0 ? (
|
|
293
386
|
<p className={CARD_DESCRIPTION_CLASS}>{labels.typesEmpty}</p>
|
|
294
387
|
) : (
|
|
295
|
-
<div className="space-y-3">
|
|
388
|
+
<div className="space-y-3">
|
|
389
|
+
{columnHeadings}
|
|
390
|
+
{rows.map(renderRow)}
|
|
391
|
+
</div>
|
|
296
392
|
);
|
|
297
393
|
}
|
|
298
394
|
|
|
@@ -13,16 +13,23 @@ import type { ReactNode } from "react";
|
|
|
13
13
|
export type DigestCadence = "weekly" | "monthly";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* One type's
|
|
17
|
-
* that channel: `email` for a type with no mail, `app` in an app with no
|
|
18
|
-
* channel (Kurvenschmiede: `{app: null, email: true}`)
|
|
19
|
-
*
|
|
16
|
+
* One type's switches in the `GET` (§3.2; §10.2: one per channel). `null` where the type
|
|
17
|
+
* does not offer that channel: `email` for a type with no mail, `app` in an app with no
|
|
18
|
+
* "in the app" channel (Kurvenschmiede: `{app: null, push: null, email: true}`), `push` in
|
|
19
|
+
* an app with no push (server-kit `NotificationTypes(..., push_channel=False)`: kastlan,
|
|
20
|
+
* KS). An unset switch already reads as its default — the server fills it in.
|
|
20
21
|
*/
|
|
21
22
|
export interface NotificationSwitches {
|
|
22
23
|
app: boolean | null;
|
|
24
|
+
/** 0.46 (§10.2): push, independent of `app`. Left out by a server before
|
|
25
|
+
* server-kit 0.16, which reads as `null` (not offered). */
|
|
26
|
+
push?: boolean | null;
|
|
23
27
|
email: boolean | null;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** A channel a type may offer (§10.2), in the order the types card draws them. */
|
|
31
|
+
export type NotificationChannel = "app" | "push" | "email";
|
|
32
|
+
|
|
26
33
|
/** The `GET`'s `types`: every type the account can receive, in the registry's order. */
|
|
27
34
|
export type NotificationTypesValue = Readonly<Record<string, NotificationSwitches>>;
|
|
28
35
|
|
|
@@ -51,16 +58,18 @@ export interface NotificationTypeGroup<Id extends string = string> {
|
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
60
|
* The parts of `PATCH /notifications/settings` (§3.2) the cards send. Omitted keeps; a
|
|
54
|
-
* type's `null` puts
|
|
61
|
+
* type's `null` puts its switches back to their defaults; `quiet_hours_*: null`
|
|
55
62
|
* clears a time. One switch per call from the types card (`{types: {digest: {app:
|
|
56
|
-
* true}}}`), or `{digest_cadence}`; quiet hours send a time
|
|
57
|
-
*
|
|
63
|
+
* true}}}`), or `{digest_cadence}`; quiet hours send a time. No zone: it is the
|
|
64
|
+
* account's, automatic only (§10.3, amended 2026-10-20).
|
|
58
65
|
*/
|
|
59
66
|
export interface NotificationSettingsPatch<Id extends string = string> {
|
|
60
|
-
types?: Partial<Record<Id, { app?: boolean; email?: boolean } | null>>;
|
|
67
|
+
types?: Partial<Record<Id, { app?: boolean; push?: boolean; email?: boolean } | null>>;
|
|
61
68
|
digest_cadence?: DigestCadence;
|
|
62
69
|
quiet_hours_start?: string | null;
|
|
63
70
|
quiet_hours_end?: string | null;
|
|
71
|
+
/** @deprecated 0.46 — the zone is the account's, automatic only (§10.3), and server-kit 0.16 refuses it here;
|
|
72
|
+
* removed in 0.47. No kit card sends it. */
|
|
64
73
|
timezone?: string;
|
|
65
74
|
}
|
|
66
75
|
|
|
@@ -77,4 +86,7 @@ export interface PushDeviceItem<Id extends string | number = string> {
|
|
|
77
86
|
last_success_at: string | null;
|
|
78
87
|
/** Failed deliveries in a row; the server removes the device at 10. */
|
|
79
88
|
failure_count: number;
|
|
89
|
+
/** 0.46 (§10.2): the type ids this device mutes — a device can mute types, never add
|
|
90
|
+
* them. Left out by a server before server-kit 0.16 (none muted). */
|
|
91
|
+
muted_types?: string[];
|
|
80
92
|
}
|