@domicile-desktop/manganese-notifications 1.1.1 → 2.0.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/package.json +10 -10
- package/src/NotificationDrawer.tsx +94 -39
- package/src/fixture.ts +1 -0
- package/src/inbox.ts +3 -2
- package/src/notifications-context.ts +2 -2
- package/src/pause.ts +54 -0
- package/src/stored-pause.ts +57 -0
- package/src/useNotifications.ts +73 -16
- package/styled-system/patterns/aspect-ratio.mjs +2 -2
- package/styled-system/patterns/bleed.mjs +1 -2
- package/styled-system/patterns/float.mjs +5 -11
- package/styled-system/patterns/grid-item.mjs +1 -2
- package/styled-system/patterns/grid.mjs +1 -2
- package/styled-system/patterns/link-overlay.mjs +1 -1
- package/styled-system/patterns/spacer.mjs +3 -3
- package/styled-system/patterns/visually-hidden.mjs +1 -1
- package/styled-system/styles.css +2049 -1967
- package/styled-system/types/prop-type.d.ts +1 -1
- package/src/do-not-disturb.ts +0 -18
package/package.json
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@domicile-desktop/component-library": "2.0
|
|
4
|
-
"@domicile-desktop/manganese-bar": "1.1.
|
|
5
|
-
"@domicile-desktop/manganese-host": "2.0.
|
|
6
|
-
"@domicile-desktop/react": "0.1.
|
|
7
|
-
"@domicile-desktop/sdk": "2.
|
|
3
|
+
"@domicile-desktop/component-library": "2.1.0",
|
|
4
|
+
"@domicile-desktop/manganese-bar": "1.1.2",
|
|
5
|
+
"@domicile-desktop/manganese-host": "2.0.2",
|
|
6
|
+
"@domicile-desktop/react": "0.1.2",
|
|
7
|
+
"@domicile-desktop/sdk": "2.2.0",
|
|
8
8
|
"@pandacss/dev": "^1.12.1",
|
|
9
9
|
"@phosphor-icons/react": "^2.1.10",
|
|
10
10
|
"react": "^19.2.8",
|
|
11
|
-
"react-dom": "^19.2.8"
|
|
11
|
+
"react-dom": "^19.2.8",
|
|
12
|
+
"zod": "^4.5.4"
|
|
12
13
|
},
|
|
13
14
|
"description": "Manganese's notification toasts and drawer.",
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@cprussin/tsconfig": "^5.0.0",
|
|
16
|
-
"@domicile-desktop/manganese-window-management": "1.
|
|
17
|
+
"@domicile-desktop/manganese-window-management": "1.3.0",
|
|
17
18
|
"@domicile-desktop/test-support": "0.0.2",
|
|
18
19
|
"@pandacss/preset-panda": "^1.12.1",
|
|
19
20
|
"@testing-library/react": "^16.3.3",
|
|
@@ -25,8 +26,7 @@
|
|
|
25
26
|
"typescript": "^7.0.2"
|
|
26
27
|
},
|
|
27
28
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.2"
|
|
29
|
-
"node": ">=24"
|
|
29
|
+
"bun": ">=1.4.2"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
"./ago": "./src/ago.ts",
|
|
@@ -60,5 +60,5 @@
|
|
|
60
60
|
"test:unit": "bun test"
|
|
61
61
|
},
|
|
62
62
|
"type": "module",
|
|
63
|
-
"version": "
|
|
63
|
+
"version": "2.0.0"
|
|
64
64
|
}
|
|
@@ -3,15 +3,17 @@ import { EmptyState } from "@domicile-desktop/component-library/EmptyState";
|
|
|
3
3
|
import { LeavingRow } from "@domicile-desktop/component-library/LeavingRow";
|
|
4
4
|
import { List } from "@domicile-desktop/component-library/ListRow";
|
|
5
5
|
import type { Row } from "@domicile-desktop/component-library/leaving-rows";
|
|
6
|
-
import {
|
|
6
|
+
import { Menu } from "@domicile-desktop/component-library/Menu";
|
|
7
7
|
import { PanelHeader } from "@domicile-desktop/component-library/PanelHeader";
|
|
8
8
|
import { PanelSection } from "@domicile-desktop/component-library/PanelSection";
|
|
9
9
|
import { Popover } from "@domicile-desktop/component-library/Popover";
|
|
10
|
-
import { Switch } from "@domicile-desktop/component-library/Switch";
|
|
11
10
|
import { useLeavingRows } from "@domicile-desktop/component-library/useLeavingRows";
|
|
12
11
|
import type { Notification } from "@domicile-desktop/sdk/notification";
|
|
13
12
|
import { BellSimpleIcon } from "@phosphor-icons/react/dist/ssr/BellSimple";
|
|
14
13
|
import { BroomIcon } from "@phosphor-icons/react/dist/ssr/Broom";
|
|
14
|
+
import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr/CaretDown";
|
|
15
|
+
import { PauseIcon } from "@phosphor-icons/react/dist/ssr/Pause";
|
|
16
|
+
import { PlayIcon } from "@phosphor-icons/react/dist/ssr/Play";
|
|
15
17
|
import { memo } from "react";
|
|
16
18
|
|
|
17
19
|
import { css } from "../styled-system/css";
|
|
@@ -21,6 +23,8 @@ import type { Inbox, InboxReading } from "./inbox";
|
|
|
21
23
|
import { NotificationCard } from "./NotificationCard";
|
|
22
24
|
import type { NotificationsHandle } from "./notifications-context";
|
|
23
25
|
import { bellId, screenOfBell } from "./notifications-context";
|
|
26
|
+
import { PauseLength, pausedFor } from "./pause";
|
|
27
|
+
import type { Pause } from "./stored-pause";
|
|
24
28
|
|
|
25
29
|
/** The inboxes listed in the drawer, and what the shell knows of them. */
|
|
26
30
|
export type DrawerInboxes = {
|
|
@@ -32,8 +36,6 @@ export type DrawerInboxes = {
|
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
type Props = {
|
|
35
|
-
/** Toasts are held back, but for critical ones. */
|
|
36
|
-
doNotDisturb: boolean;
|
|
37
39
|
/** Opens it under the bell pressed. */
|
|
38
40
|
handle: NotificationsHandle;
|
|
39
41
|
/** The inboxes, listed above the notifications. */
|
|
@@ -43,6 +45,8 @@ type Props = {
|
|
|
43
45
|
/** The current time, for the relative timestamps. */
|
|
44
46
|
now: number;
|
|
45
47
|
open: boolean;
|
|
48
|
+
/** Toasts are held back, but for critical ones, until it ends. */
|
|
49
|
+
pause: Pause | undefined;
|
|
46
50
|
/** The monitor whose bell it opens under. */
|
|
47
51
|
screen: string | undefined;
|
|
48
52
|
/** The bell on monitor `screen` was pressed. */
|
|
@@ -50,8 +54,10 @@ type Props = {
|
|
|
50
54
|
onClose: () => void;
|
|
51
55
|
/** Clear `ids`. */
|
|
52
56
|
onDismiss: (ids: readonly number[]) => void;
|
|
53
|
-
/**
|
|
54
|
-
|
|
57
|
+
/** Pause toasts for `length`. */
|
|
58
|
+
onPause: (length: PauseLength) => void;
|
|
59
|
+
/** End the pause. */
|
|
60
|
+
onResume: () => void;
|
|
55
61
|
/** Press `key` on `id`. */
|
|
56
62
|
onAction: (id: number, key: string) => void;
|
|
57
63
|
/** Send `text` from `id`'s reply field. */
|
|
@@ -61,8 +67,8 @@ type Props = {
|
|
|
61
67
|
/**
|
|
62
68
|
* The notification drawer, a panel under the bell. Each notification can be
|
|
63
69
|
* pressed or cleared, or all cleared at once. A press closes the drawer,
|
|
64
|
-
* which would cover the window it raises.
|
|
65
|
-
*
|
|
70
|
+
* which would cover the window it raises. Its header pauses notifications from
|
|
71
|
+
* a menu of lengths, and says until when while paused.
|
|
66
72
|
*
|
|
67
73
|
* A notification stays here after its toast is gone, until it is cleared or its
|
|
68
74
|
* application withdraws it. See docs/architecture/NOTIFICATIONS.md.
|
|
@@ -75,7 +81,6 @@ type Props = {
|
|
|
75
81
|
*/
|
|
76
82
|
export const NotificationDrawer = memo(
|
|
77
83
|
({
|
|
78
|
-
doNotDisturb,
|
|
79
84
|
handle,
|
|
80
85
|
inboxes,
|
|
81
86
|
items,
|
|
@@ -83,10 +88,12 @@ export const NotificationDrawer = memo(
|
|
|
83
88
|
onAction,
|
|
84
89
|
onClose,
|
|
85
90
|
onDismiss,
|
|
86
|
-
onDoNotDisturb,
|
|
87
91
|
onOpen,
|
|
92
|
+
onPause,
|
|
88
93
|
onReply,
|
|
94
|
+
onResume,
|
|
89
95
|
open,
|
|
96
|
+
pause,
|
|
90
97
|
screen,
|
|
91
98
|
}: Props) => {
|
|
92
99
|
const { onLeft, rows } = useLeavingRows(items, idOf, open);
|
|
@@ -117,7 +124,26 @@ export const NotificationDrawer = memo(
|
|
|
117
124
|
triggerId={screen === undefined ? null : bellId(screen)}
|
|
118
125
|
>
|
|
119
126
|
<section aria-labelledby={TITLE_ID} className={drawerStyles}>
|
|
120
|
-
<PanelHeader
|
|
127
|
+
<PanelHeader
|
|
128
|
+
action={
|
|
129
|
+
pause === undefined ? (
|
|
130
|
+
<PauseMenu onPause={onPause} />
|
|
131
|
+
) : (
|
|
132
|
+
<Button
|
|
133
|
+
beforeIcon={<PlayIcon aria-hidden size={12} weight="fill" />}
|
|
134
|
+
onClick={onResume}
|
|
135
|
+
rounded
|
|
136
|
+
size="xs"
|
|
137
|
+
variant="accent"
|
|
138
|
+
>
|
|
139
|
+
Resume
|
|
140
|
+
</Button>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
status={pause === undefined ? undefined : pausedFor(pause, now)}
|
|
144
|
+
title="Notifications"
|
|
145
|
+
titleId={TITLE_ID}
|
|
146
|
+
/>
|
|
121
147
|
<div className={paneStyles}>
|
|
122
148
|
{inboxes !== undefined && (
|
|
123
149
|
<PanelSection label="Inboxes">
|
|
@@ -130,41 +156,70 @@ export const NotificationDrawer = memo(
|
|
|
130
156
|
</List>
|
|
131
157
|
</PanelSection>
|
|
132
158
|
)}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
<PanelSection
|
|
160
|
+
action={
|
|
161
|
+
items.length > 0 && (
|
|
162
|
+
<Button
|
|
163
|
+
beforeIcon={<BroomIcon aria-hidden size={14} />}
|
|
164
|
+
onClick={() => {
|
|
165
|
+
onDismiss(items.map(({ id }) => id));
|
|
166
|
+
}}
|
|
167
|
+
rounded
|
|
168
|
+
size="xs"
|
|
169
|
+
variant="ghost"
|
|
170
|
+
>
|
|
171
|
+
Clear all
|
|
172
|
+
</Button>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
label="Recent"
|
|
176
|
+
>
|
|
177
|
+
{notifications}
|
|
178
|
+
</PanelSection>
|
|
138
179
|
</div>
|
|
139
|
-
<PanelFooter
|
|
140
|
-
action={
|
|
141
|
-
items.length > 0 && (
|
|
142
|
-
<Button
|
|
143
|
-
beforeIcon={<BroomIcon aria-hidden size={14} />}
|
|
144
|
-
onClick={() => {
|
|
145
|
-
onDismiss(items.map(({ id }) => id));
|
|
146
|
-
}}
|
|
147
|
-
rounded
|
|
148
|
-
size="xs"
|
|
149
|
-
variant="ghost"
|
|
150
|
-
>
|
|
151
|
-
Clear all
|
|
152
|
-
</Button>
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
>
|
|
156
|
-
<Switch
|
|
157
|
-
checked={doNotDisturb}
|
|
158
|
-
label="Do not disturb"
|
|
159
|
-
onCheckedChange={onDoNotDisturb}
|
|
160
|
-
/>
|
|
161
|
-
</PanelFooter>
|
|
162
180
|
</section>
|
|
163
181
|
</Popover>
|
|
164
182
|
);
|
|
165
183
|
},
|
|
166
184
|
);
|
|
167
185
|
|
|
186
|
+
type PauseMenuProps = Pick<Props, "onPause">;
|
|
187
|
+
|
|
188
|
+
/** The header's Pause button, and the lengths it offers. */
|
|
189
|
+
const PauseMenu = ({ onPause }: PauseMenuProps) => (
|
|
190
|
+
<Menu
|
|
191
|
+
trigger={
|
|
192
|
+
<Button
|
|
193
|
+
afterIcon={<CaretDownIcon aria-hidden size={10} weight="fill" />}
|
|
194
|
+
beforeIcon={<PauseIcon aria-hidden size={12} weight="fill" />}
|
|
195
|
+
rounded
|
|
196
|
+
size="xs"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
>
|
|
199
|
+
Pause
|
|
200
|
+
</Button>
|
|
201
|
+
}
|
|
202
|
+
>
|
|
203
|
+
{PAUSE_LENGTHS.map(({ label, length }) => (
|
|
204
|
+
<Menu.Item
|
|
205
|
+
key={label}
|
|
206
|
+
onClick={() => {
|
|
207
|
+
onPause(length);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{label}
|
|
211
|
+
</Menu.Item>
|
|
212
|
+
))}
|
|
213
|
+
</Menu>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
/** The Pause menu's items, shortest first. */
|
|
217
|
+
const PAUSE_LENGTHS = [
|
|
218
|
+
{ label: "For 1 hour", length: PauseLength.Hour },
|
|
219
|
+
{ label: "Until tomorrow", length: PauseLength.Tomorrow },
|
|
220
|
+
{ label: "Until I resume", length: PauseLength.UntilResumed },
|
|
221
|
+
];
|
|
222
|
+
|
|
168
223
|
/** Tells one notification from another. */
|
|
169
224
|
const idOf = ({ id }: Notification): number => id;
|
|
170
225
|
|
package/src/fixture.ts
CHANGED
package/src/inbox.ts
CHANGED
|
@@ -107,9 +107,10 @@ export const Inbox = {
|
|
|
107
107
|
open: (domicile: Domicile, reading: InboxReading | undefined) => void;
|
|
108
108
|
/**
|
|
109
109
|
* The app window it opens in, if any, which the launcher lists: app
|
|
110
|
-
* `appId`'s, at `url`.
|
|
110
|
+
* `appId`'s, at `url`. With `pageNotifies`, its page notifies what the
|
|
111
|
+
* inbox notifies already, so the page's are cleared as they arrive.
|
|
111
112
|
*/
|
|
112
|
-
app?: { appId: string; url: string };
|
|
113
|
+
app?: { appId: string; url: string; pageNotifies?: boolean };
|
|
113
114
|
},
|
|
114
115
|
) => ({ ...inbox, kind: InboxKind.Watched as const }),
|
|
115
116
|
/**
|
|
@@ -7,10 +7,10 @@ export type NotificationsHandle = ReturnType<typeof createHandle>;
|
|
|
7
7
|
|
|
8
8
|
/** The notification drawer, for the bell that opens it. */
|
|
9
9
|
export type NotificationsDrawer = {
|
|
10
|
-
/** Toasts are held back, but for critical ones. */
|
|
11
|
-
doNotDisturb: boolean;
|
|
12
10
|
/** Opens the drawer under the bell pressed. */
|
|
13
11
|
handle: NotificationsHandle;
|
|
12
|
+
/** Toasts are held back, but for critical ones. */
|
|
13
|
+
paused: boolean;
|
|
14
14
|
/** How many notifications arrived since the drawer was last opened. */
|
|
15
15
|
unread: number;
|
|
16
16
|
};
|
package/src/pause.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Pause } from "./stored-pause";
|
|
2
|
+
|
|
3
|
+
/** How long a pause lasts, as picked from the drawer's Pause menu. */
|
|
4
|
+
export enum PauseLength {
|
|
5
|
+
Hour,
|
|
6
|
+
Tomorrow,
|
|
7
|
+
UntilResumed,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const HOUR_MS = 3_600_000;
|
|
11
|
+
|
|
12
|
+
/** The hour of the morning a pause until tomorrow ends. */
|
|
13
|
+
const MORNING_HOUR = 8;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* When a pause of `length` started at `now` ends, in ms since the epoch, or
|
|
17
|
+
* `undefined` for one that lasts until resumed.
|
|
18
|
+
*/
|
|
19
|
+
export const pauseEnd = (
|
|
20
|
+
length: PauseLength,
|
|
21
|
+
now: number,
|
|
22
|
+
): number | undefined => {
|
|
23
|
+
switch (length) {
|
|
24
|
+
case PauseLength.Hour: {
|
|
25
|
+
return now + HOUR_MS;
|
|
26
|
+
}
|
|
27
|
+
case PauseLength.Tomorrow: {
|
|
28
|
+
return nextMorning(new Date(now)).getTime();
|
|
29
|
+
}
|
|
30
|
+
case PauseLength.UntilResumed: {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** What the drawer says under its title while `pause` holds at `now`. */
|
|
37
|
+
export const pausedFor = ({ until }: Pause, now: number): string =>
|
|
38
|
+
until === undefined ? "Paused" : `Paused until ${endsAt(until, now)}`;
|
|
39
|
+
|
|
40
|
+
/** 8 AM the day after `now`'s date, local time. */
|
|
41
|
+
const nextMorning = (now: Date): Date =>
|
|
42
|
+
new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, MORNING_HOUR);
|
|
43
|
+
|
|
44
|
+
/** `until`'s time, after "tomorrow" when that is not `now`'s date. */
|
|
45
|
+
const endsAt = (until: number, now: number): string => {
|
|
46
|
+
const end = new Date(until);
|
|
47
|
+
const time = end.toLocaleTimeString(undefined, {
|
|
48
|
+
hour: "numeric",
|
|
49
|
+
minute: "2-digit",
|
|
50
|
+
});
|
|
51
|
+
return end.toDateString() === new Date(now).toDateString()
|
|
52
|
+
? time
|
|
53
|
+
: `tomorrow, ${time}`;
|
|
54
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// The notifications pause, stored locally so a reload keeps it. Every page of
|
|
4
|
+
// the desktop shares one origin, so they read the same pause.
|
|
5
|
+
|
|
6
|
+
/** The storage key, versioned per the persisted-state rule. */
|
|
7
|
+
const KEY = "notifications-paused:v1";
|
|
8
|
+
|
|
9
|
+
/** The key do not disturb was stored under; on means paused until resumed. */
|
|
10
|
+
const DO_NOT_DISTURB_KEY = "do-not-disturb:v1";
|
|
11
|
+
|
|
12
|
+
const pauseSchema = z.object({ until: z.number().optional() });
|
|
13
|
+
|
|
14
|
+
/** Notifications paused until `until` (ms since the epoch) or until resumed. */
|
|
15
|
+
export type Pause = z.infer<typeof pauseSchema>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The stored pause, or none. Do not disturb left on reads as a pause until
|
|
19
|
+
* resumed.
|
|
20
|
+
*
|
|
21
|
+
* Parsed rather than cast: an unreadable value counts as none, and
|
|
22
|
+
* notifications toast.
|
|
23
|
+
*/
|
|
24
|
+
export const storedPause = (): Pause | undefined => {
|
|
25
|
+
const stored = globalThis.localStorage.getItem(KEY);
|
|
26
|
+
if (stored === null) {
|
|
27
|
+
return globalThis.localStorage.getItem(DO_NOT_DISTURB_KEY) === "on"
|
|
28
|
+
? {}
|
|
29
|
+
: undefined;
|
|
30
|
+
} else {
|
|
31
|
+
return storedPauseSchema.safeParse(stored).data;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Store `pause` for other pages and future loads, or clear it. */
|
|
36
|
+
export const storePause = (pause: Pause | undefined): void => {
|
|
37
|
+
globalThis.localStorage.removeItem(DO_NOT_DISTURB_KEY);
|
|
38
|
+
if (pause === undefined) {
|
|
39
|
+
globalThis.localStorage.removeItem(KEY);
|
|
40
|
+
} else {
|
|
41
|
+
globalThis.localStorage.setItem(KEY, JSON.stringify(pause));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Invalid JSON is a parse issue, not a throw, so it is handled like any other
|
|
46
|
+
// invalid value.
|
|
47
|
+
const storedPauseSchema = z
|
|
48
|
+
.string()
|
|
49
|
+
.transform((text, context): unknown => {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(text);
|
|
52
|
+
} catch {
|
|
53
|
+
context.addIssue({ code: "custom", message: "not JSON" });
|
|
54
|
+
return z.NEVER;
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.pipe(pauseSchema);
|
package/src/useNotifications.ts
CHANGED
|
@@ -4,7 +4,10 @@ import type { Domicile } from "@domicile-desktop/sdk/desktop/desktop";
|
|
|
4
4
|
import type { Notification } from "@domicile-desktop/sdk/notification";
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { arrivals } from "./arrivals";
|
|
7
|
-
import {
|
|
7
|
+
import type { PauseLength } from "./pause";
|
|
8
|
+
import { pauseEnd } from "./pause";
|
|
9
|
+
import type { Pause } from "./stored-pause";
|
|
10
|
+
import { storedPause, storePause } from "./stored-pause";
|
|
8
11
|
import { toastTimeout } from "./toast-timeout";
|
|
9
12
|
|
|
10
13
|
export type NotificationCenter = {
|
|
@@ -23,9 +26,13 @@ export type NotificationCenter = {
|
|
|
23
26
|
invoke: (id: number, action: string) => void;
|
|
24
27
|
/** Send `text` from `id`'s reply field. */
|
|
25
28
|
reply: (id: number, text: string) => void;
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Holds back every toast but a critical one, or `undefined` while not
|
|
31
|
+
* paused. Kept through a reload.
|
|
32
|
+
*/
|
|
33
|
+
pause: Pause | undefined;
|
|
34
|
+
pauseFor: (length: PauseLength) => void;
|
|
35
|
+
resume: () => void;
|
|
29
36
|
};
|
|
30
37
|
|
|
31
38
|
/** The notifications and how far the user has read. */
|
|
@@ -49,14 +56,22 @@ type Desk = {
|
|
|
49
56
|
*
|
|
50
57
|
* A toast only interrupts. Closing it or timing out leaves the notification in
|
|
51
58
|
* place. Clearing the notification (on any page) or its application closing it
|
|
52
|
-
* removes the toast too.
|
|
53
|
-
*
|
|
59
|
+
* removes the toast too. A pause holds back every toast but a critical one;
|
|
60
|
+
* the notifications still collect. A timed pause ends by itself. See
|
|
61
|
+
* docs/architecture/NOTIFICATIONS.md.
|
|
54
62
|
*/
|
|
55
63
|
export const useNotifications = (
|
|
56
64
|
domicile: Pick<Domicile, "notifications">,
|
|
57
65
|
toasts: ToastManager,
|
|
58
66
|
/** The senders `unread` leaves out, by `appName`, such as the inboxes'. */
|
|
59
67
|
uncounted: readonly string[],
|
|
68
|
+
/**
|
|
69
|
+
* The browser windows whose pages' notifications are cleared as they
|
|
70
|
+
* arrive, unshown, because another sends the same news: an inbox's window,
|
|
71
|
+
* when the inbox notifies its mail itself.
|
|
72
|
+
*/
|
|
73
|
+
replaced: readonly string[],
|
|
74
|
+
clock: typeof wallClock = wallClock,
|
|
60
75
|
): NotificationCenter => {
|
|
61
76
|
const [desk, setDesk] = useState<Desk>({
|
|
62
77
|
items: [],
|
|
@@ -65,12 +80,22 @@ export const useNotifications = (
|
|
|
65
80
|
// The last list received, for `arrivals`. A ref because it is only used in
|
|
66
81
|
// the handler, not for rendering.
|
|
67
82
|
const told = useRef<readonly Notification[] | undefined>(undefined);
|
|
68
|
-
const [
|
|
83
|
+
const [pause, setPause] = useState(() => unended(storedPause(), clock()));
|
|
69
84
|
// Read by the handler, which subscribes once.
|
|
70
|
-
const quiet = useRef(
|
|
85
|
+
const quiet = useRef(pause !== undefined);
|
|
71
86
|
|
|
72
87
|
useEffect(() => {
|
|
73
|
-
const heard = (
|
|
88
|
+
const heard = (sent: readonly Notification[]) => {
|
|
89
|
+
const duplicates = sent
|
|
90
|
+
.filter(
|
|
91
|
+
({ browserWindow }) =>
|
|
92
|
+
browserWindow !== undefined && replaced.includes(browserWindow),
|
|
93
|
+
)
|
|
94
|
+
.map(({ id }) => id);
|
|
95
|
+
if (duplicates.length > 0) {
|
|
96
|
+
domicile.notifications.dismiss(duplicates);
|
|
97
|
+
}
|
|
98
|
+
const items = sent.filter(({ id }) => !duplicates.includes(id));
|
|
74
99
|
for (const arrived of arrivals(told.current, items).filter(
|
|
75
100
|
({ urgency }) => !quiet.current || urgency === "critical",
|
|
76
101
|
)) {
|
|
@@ -100,18 +125,42 @@ export const useNotifications = (
|
|
|
100
125
|
() => notifications.list,
|
|
101
126
|
heard,
|
|
102
127
|
);
|
|
103
|
-
}, [domicile, toasts]);
|
|
128
|
+
}, [domicile, replaced, toasts]);
|
|
104
129
|
|
|
105
130
|
const read = useCallback(() => {
|
|
106
131
|
setDesk((previous) => ({ ...previous, readUpTo: latest(previous.items) }));
|
|
107
132
|
}, []);
|
|
108
133
|
|
|
109
|
-
const
|
|
110
|
-
quiet.current =
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
const pauseAs = useCallback((next: Pause | undefined) => {
|
|
135
|
+
quiet.current = next !== undefined;
|
|
136
|
+
storePause(next);
|
|
137
|
+
setPause(next);
|
|
113
138
|
}, []);
|
|
114
139
|
|
|
140
|
+
const pauseFor = useCallback(
|
|
141
|
+
(length: PauseLength) => {
|
|
142
|
+
const until = pauseEnd(length, clock());
|
|
143
|
+
pauseAs(until === undefined ? {} : { until });
|
|
144
|
+
},
|
|
145
|
+
[clock, pauseAs],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const resume = useCallback(() => {
|
|
149
|
+
pauseAs(undefined);
|
|
150
|
+
}, [pauseAs]);
|
|
151
|
+
|
|
152
|
+
const until = pause?.until;
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (until === undefined) {
|
|
155
|
+
return undefined;
|
|
156
|
+
} else {
|
|
157
|
+
const timer = setTimeout(resume, until - clock());
|
|
158
|
+
return () => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}, [clock, resume, until]);
|
|
163
|
+
|
|
115
164
|
const dismiss = useCallback(
|
|
116
165
|
(ids: readonly number[]) => {
|
|
117
166
|
domicile.notifications.dismiss(ids);
|
|
@@ -135,12 +184,13 @@ export const useNotifications = (
|
|
|
135
184
|
|
|
136
185
|
return {
|
|
137
186
|
dismiss,
|
|
138
|
-
doNotDisturb,
|
|
139
187
|
invoke,
|
|
140
188
|
items: desk.items.toReversed(),
|
|
189
|
+
pause,
|
|
190
|
+
pauseFor,
|
|
141
191
|
read,
|
|
142
192
|
reply,
|
|
143
|
-
|
|
193
|
+
resume,
|
|
144
194
|
unread: desk.items.filter(
|
|
145
195
|
({ appName, time }) =>
|
|
146
196
|
time > (desk.readUpTo ?? 0) && !uncounted.includes(appName),
|
|
@@ -148,6 +198,13 @@ export const useNotifications = (
|
|
|
148
198
|
};
|
|
149
199
|
};
|
|
150
200
|
|
|
201
|
+
/** The wall clock in ms since the epoch; injected so tests can control it. */
|
|
202
|
+
const wallClock = (): number => Date.now();
|
|
203
|
+
|
|
204
|
+
/** `pause`, unless it ended by `now`. */
|
|
205
|
+
const unended = (pause: Pause | undefined, now: number): Pause | undefined =>
|
|
206
|
+
pause?.until !== undefined && pause.until <= now ? undefined : pause;
|
|
207
|
+
|
|
151
208
|
/** The newest `time` in `items`, or `0` for none. */
|
|
152
209
|
const latest = (items: readonly Notification[]): number =>
|
|
153
210
|
Math.max(0, ...items.map(({ time }) => time));
|
|
@@ -3,11 +3,11 @@ import { css } from '../css/index.mjs';
|
|
|
3
3
|
|
|
4
4
|
const aspectRatioConfig = {
|
|
5
5
|
transform(props, { map }) {
|
|
6
|
-
const { ratio =
|
|
6
|
+
const { ratio = 1.3333333333333333, ...rest } = props;
|
|
7
7
|
return {
|
|
8
8
|
position: "relative",
|
|
9
9
|
_before: {
|
|
10
|
-
content:
|
|
10
|
+
content: '""',
|
|
11
11
|
display: "block",
|
|
12
12
|
height: "0",
|
|
13
13
|
paddingBottom: map(ratio, (r) => `${1 / r * 100}%`)
|
|
@@ -3,8 +3,7 @@ import { css } from '../css/index.mjs';
|
|
|
3
3
|
|
|
4
4
|
const bleedConfig = {
|
|
5
5
|
transform(props, { map, isCssUnit, isCssVar }) {
|
|
6
|
-
const { inline, block, ...rest } = props
|
|
7
|
-
const valueFn = (v) => isCssUnit(v) || isCssVar(v) ? v : `token(spacing.${v}, ${v})`;
|
|
6
|
+
const { inline, block, ...rest } = props, valueFn = (v) => isCssUnit(v) || isCssVar(v) ? v : `token(spacing.${v}, ${v})`;
|
|
8
7
|
return {
|
|
9
8
|
"--bleed-x": map(inline, valueFn),
|
|
10
9
|
"--bleed-y": map(block, valueFn),
|
|
@@ -11,28 +11,22 @@ transform(props, { map }) {
|
|
|
11
11
|
position: "absolute",
|
|
12
12
|
insetBlockStart: map(placement, (v) => {
|
|
13
13
|
const [side] = v.split("-");
|
|
14
|
-
|
|
15
|
-
return map2[side];
|
|
14
|
+
return { top: offsetY, middle: "50%", bottom: "auto" }[side];
|
|
16
15
|
}),
|
|
17
16
|
insetBlockEnd: map(placement, (v) => {
|
|
18
17
|
const [side] = v.split("-");
|
|
19
|
-
|
|
20
|
-
return map2[side];
|
|
18
|
+
return { top: "auto", middle: "50%", bottom: offsetY }[side];
|
|
21
19
|
}),
|
|
22
20
|
insetInlineStart: map(placement, (v) => {
|
|
23
21
|
const [, align] = v.split("-");
|
|
24
|
-
|
|
25
|
-
return map2[align];
|
|
22
|
+
return { start: offsetX, center: "50%", end: "auto" }[align];
|
|
26
23
|
}),
|
|
27
24
|
insetInlineEnd: map(placement, (v) => {
|
|
28
25
|
const [, align] = v.split("-");
|
|
29
|
-
|
|
30
|
-
return map2[align];
|
|
26
|
+
return { start: "auto", center: "50%", end: offsetX }[align];
|
|
31
27
|
}),
|
|
32
28
|
translate: map(placement, (v) => {
|
|
33
|
-
const [side, align] = v.split("-");
|
|
34
|
-
const mapX = { start: "-50%", center: "-50%", end: "50%" };
|
|
35
|
-
const mapY = { top: "-50%", middle: "-50%", bottom: "50%" };
|
|
29
|
+
const [side, align] = v.split("-"), mapX = { start: "-50%", center: "-50%", end: "50%" }, mapY = { top: "-50%", middle: "-50%", bottom: "50%" };
|
|
36
30
|
return `${mapX[align]} ${mapY[side]}`;
|
|
37
31
|
}),
|
|
38
32
|
...rest
|
|
@@ -3,8 +3,7 @@ import { css } from '../css/index.mjs';
|
|
|
3
3
|
|
|
4
4
|
const gridItemConfig = {
|
|
5
5
|
transform(props, { map }) {
|
|
6
|
-
const { colSpan, rowSpan, colStart, rowStart, colEnd, rowEnd, ...rest } = props
|
|
7
|
-
const spanFn = (v) => v === "auto" ? v : `span ${v}`;
|
|
6
|
+
const { colSpan, rowSpan, colStart, rowStart, colEnd, rowEnd, ...rest } = props, spanFn = (v) => v === "auto" ? v : `span ${v}`;
|
|
8
7
|
return {
|
|
9
8
|
gridColumn: colSpan != null ? map(colSpan, spanFn) : void 0,
|
|
10
9
|
gridRow: rowSpan != null ? map(rowSpan, spanFn) : void 0,
|
|
@@ -3,8 +3,7 @@ import { css } from '../css/index.mjs';
|
|
|
3
3
|
|
|
4
4
|
const gridConfig = {
|
|
5
5
|
transform(props, { map, isCssUnit }) {
|
|
6
|
-
const { columnGap, rowGap, gap, columns, minChildWidth, ...rest } = props
|
|
7
|
-
const getValue = (v) => isCssUnit(v) ? v : `token(sizes.${v}, ${v})`;
|
|
6
|
+
const { columnGap, rowGap, gap, columns, minChildWidth, ...rest } = props, getValue = (v) => isCssUnit(v) ? v : `token(sizes.${v}, ${v})`;
|
|
8
7
|
return {
|
|
9
8
|
display: "grid",
|
|
10
9
|
gridTemplateColumns: columns != null ? map(columns, (v) => `repeat(${v}, minmax(0, 1fr))`) : minChildWidth != null ? map(minChildWidth, (v) => `repeat(auto-fit, minmax(${getValue(v)}, 1fr))`) : void 0,
|
|
@@ -8,9 +8,9 @@ transform(props, { map, isCssUnit, isCssVar }) {
|
|
|
8
8
|
alignSelf: "stretch",
|
|
9
9
|
justifySelf: "stretch",
|
|
10
10
|
flex: map(size, (v) => {
|
|
11
|
-
if (v == null)
|
|
12
|
-
|
|
13
|
-
return `0 0 ${
|
|
11
|
+
if (v == null)
|
|
12
|
+
return "1";
|
|
13
|
+
return `0 0 ${isCssUnit(v) || isCssVar(v) ? v : `token(spacing.${v}, ${v})`}`;
|
|
14
14
|
}),
|
|
15
15
|
...rest
|
|
16
16
|
};
|