@12-apps/notifications 4.9.0 → 4.10.1
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/ADOPTING.md +60 -1
- package/README.md +73 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/{chunk-M5XTROYM.js → chunk-I5QUMTCN.js} +141 -17
- package/dist/chunk-I5QUMTCN.js.map +1 -0
- package/dist/chunk-RTURLH5U.js +33 -0
- package/dist/chunk-RTURLH5U.js.map +1 -0
- package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
- package/dist/{chunk-KZG7QGFA.js → chunk-WVRODNXQ.js} +8 -4
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/chunk-ZY32PC34.js +231 -0
- package/dist/chunk-ZY32PC34.js.map +1 -0
- package/dist/{create-api-notifications-B-VMezVB.d.ts → create-api-notifications-CcPYrM3p.d.ts} +4 -4
- package/dist/{create-web-notifications-B-xQ6R0B.d.ts → create-web-notifications-DV3Y8k7e.d.ts} +89 -4
- package/dist/{generators-BsuXe8Mp.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +5 -5
- package/dist/hono/index.js +4 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +12 -2
- package/dist/{jobs-D64B_bg7.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/server.d.ts +6 -6
- package/dist/manifest/server.js +5 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +4 -3
- package/dist/manifest/web.js +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-OPB3DBLJ.js} +116 -61
- package/dist/panel-OPB3DBLJ.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/react/index.d.ts +119 -7
- package/dist/react/index.js +8 -2
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +5 -4
- package/dist/{types-BFmAY0P5.d.ts → types-BlqZkCWZ.d.ts} +10 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-D-ysQd5h.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-_coV3tJk.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +2 -2
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/react/bell-button.tsx +112 -16
- package/src/react/create-web-notifications.tsx +40 -5
- package/src/react/index.ts +29 -0
- package/src/react/live-card.tsx +279 -0
- package/src/react/live-config.ts +79 -0
- package/src/react/live-section.tsx +179 -0
- package/src/react/live-seen.ts +138 -0
- package/src/react/panel-lazy.tsx +6 -0
- package/src/react/panel.tsx +144 -34
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-BGI4BQ66.js.map +0 -1
- package/dist/chunk-KZG7QGFA.js.map +0 -1
- package/dist/chunk-M5XTROYM.js.map +0 -1
- package/dist/chunk-O5BVUXPO.js +0 -22
- package/dist/chunk-O5BVUXPO.js.map +0 -1
- package/dist/panel-UFXNO4AF.js.map +0 -1
- /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
- /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
- /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE pinned live entry: a mark, what is happening, its lane, and when it last
|
|
3
|
+
* moved.
|
|
4
|
+
*
|
|
5
|
+
* Visually a WASH rather than a fill — a tinted card with a brand-tinted border
|
|
6
|
+
* — for the reason the inbox's unread row uses the same treatment: this sits at
|
|
7
|
+
* the top of a list of other people's news, and a saturated block there
|
|
8
|
+
* out-shouts everything it is supposed to be introducing.
|
|
9
|
+
*
|
|
10
|
+
* ## The card is a DIV, and the button is inside it
|
|
11
|
+
*
|
|
12
|
+
* The obvious shape — one `<button>` wrapping the whole card — is not
|
|
13
|
+
* available, because `Stepper` draws every stop as a real `<button>`
|
|
14
|
+
* (`@12-apps/ui`'s `StepButton` is `styled(Button)`), and `clickable={false}`
|
|
15
|
+
* only sets `pointer-events: none`. A button inside a button is invalid HTML:
|
|
16
|
+
* the parser auto-closes the outer one at the first nested one, so any host
|
|
17
|
+
* that server-renders the panel open hydrates against a tree the browser
|
|
18
|
+
* rewrote, and every adopter's dev console carries a React error besides.
|
|
19
|
+
*
|
|
20
|
+
* `aria-hidden` and `inert` on the lane fix the tab stops and the accessible
|
|
21
|
+
* name — they do NOT fix the nesting, and an earlier draft of this file claimed
|
|
22
|
+
* they did. So the tap target is the TEXT block, and the lane and the timestamp
|
|
23
|
+
* are its siblings: valid markup, and a target that still covers everything a
|
|
24
|
+
* reader would aim at.
|
|
25
|
+
*/
|
|
26
|
+
import { useId, type JSX, type ReactNode } from 'react';
|
|
27
|
+
|
|
28
|
+
import { Stepper } from '@12-apps/ui/data-display/Stepper';
|
|
29
|
+
import { Box } from '@12-apps/ui/mui/Box';
|
|
30
|
+
import { alpha, type Theme } from '@12-apps/ui/mui/styles';
|
|
31
|
+
import { Text } from '@12-apps/ui/typography/Text';
|
|
32
|
+
|
|
33
|
+
import { liveActivityLane, type LiveActivity } from '../live';
|
|
34
|
+
import type { NotificationMessages } from '../messages';
|
|
35
|
+
|
|
36
|
+
import type { LiveActivitiesConfig, LiveActivityMessages } from './live-config';
|
|
37
|
+
import { relativeTime } from './relative-time';
|
|
38
|
+
|
|
39
|
+
const cardSx = {
|
|
40
|
+
// `relative`, so the button below can stretch a hit area over the whole card
|
|
41
|
+
// — see `targetSx`.
|
|
42
|
+
position: 'relative',
|
|
43
|
+
border: '1px solid',
|
|
44
|
+
borderColor: (t: Theme) => alpha(t.palette.primary.main, 0.35),
|
|
45
|
+
bgcolor: (t: Theme) => alpha(t.palette.primary.main, 0.06),
|
|
46
|
+
borderRadius: 1.5,
|
|
47
|
+
p: 1.25,
|
|
48
|
+
mb: 1,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/** The text block: the mark, the heading and the sentence under it. */
|
|
52
|
+
const targetSx = {
|
|
53
|
+
display: 'flex',
|
|
54
|
+
alignItems: 'center',
|
|
55
|
+
gap: 1,
|
|
56
|
+
width: '100%',
|
|
57
|
+
textAlign: 'left',
|
|
58
|
+
font: 'inherit',
|
|
59
|
+
color: 'inherit',
|
|
60
|
+
border: 'none',
|
|
61
|
+
background: 'none',
|
|
62
|
+
p: 0,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The button, stretched over the WHOLE card.
|
|
67
|
+
*
|
|
68
|
+
* Taking the lane out of the link fixed the markup and left the card looking
|
|
69
|
+
* like one target while only its top half was one — the lane is the most
|
|
70
|
+
* visually distinctive part of it, and aiming at the obvious thing did nothing.
|
|
71
|
+
*
|
|
72
|
+
* A stretched pseudo-element is the remedy that keeps the structure: the
|
|
73
|
+
* `<button>` stays a sibling of the lane in the tree, so nothing nests, and its
|
|
74
|
+
* `::after` covers the card. All three declarations are load-bearing — a
|
|
75
|
+
* pseudo-element with no `content` generates no box at all, and an absolutely
|
|
76
|
+
* positioned box with auto offsets is 0×0.
|
|
77
|
+
*
|
|
78
|
+
* ## What actually lets a click on the LANE reach it
|
|
79
|
+
*
|
|
80
|
+
* Not paint order. `@12-apps/ui` gives each `StepItem` `position: relative`, and
|
|
81
|
+
* this overlay is positioned too — so the two sit in the SAME painting layer
|
|
82
|
+
* (positioned, `z-index: auto`), where tree order decides, and the lane comes
|
|
83
|
+
* after the button. Every stop, and its label, therefore sits over this
|
|
84
|
+
* overlay. `clickable={false}` does not save it either: the package puts
|
|
85
|
+
* `pointer-events: none` on the step CIRCLE and not on the label beside it.
|
|
86
|
+
*
|
|
87
|
+
* It is `inert` on {@link ActivityLane} that does it: an inert subtree is
|
|
88
|
+
* skipped by hit-testing, so a click on a stop falls through to the overlay
|
|
89
|
+
* underneath. That makes the attribute load-bearing for the TARGET as well as
|
|
90
|
+
* for the tab order it was added for — remove it and the lane silently swallows
|
|
91
|
+
* clicks again, which is why the two are pinned by one test.
|
|
92
|
+
*/
|
|
93
|
+
const stretchedSx = {
|
|
94
|
+
...targetSx,
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
'&::after': { content: '""', position: 'absolute', inset: 0 },
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Make a four-stop lane fit the panel.
|
|
101
|
+
*
|
|
102
|
+
* The drawer is 400px on a desktop and the full viewport on a phone, so the
|
|
103
|
+
* narrow case is ~320px of card minus its padding. `Stepper` renders its labels
|
|
104
|
+
* at `body2` for every size but `sm` and reserves 24px of connector plus 8px of
|
|
105
|
+
* margin on each side, which is more row than four short words have — measured
|
|
106
|
+
* on a 320px viewport, the last stop hung off the edge and the DRAWER scrolled
|
|
107
|
+
* sideways.
|
|
108
|
+
*
|
|
109
|
+
* Three overrides, each buying back a specific number of pixels: 11px labels, a
|
|
110
|
+
* step column allowed to shrink below the package's 44px floor (so the row's
|
|
111
|
+
* min-content width is the longest WORD rather than the longest phrase), and
|
|
112
|
+
* thinner connectors. `overflow: hidden` is the backstop and not the mechanism
|
|
113
|
+
* — a locale with longer words than any of this anticipates clips its own card
|
|
114
|
+
* instead of making the panel scroll.
|
|
115
|
+
*/
|
|
116
|
+
const laneSx = {
|
|
117
|
+
pt: 1.25,
|
|
118
|
+
px: 0.5,
|
|
119
|
+
overflow: 'hidden',
|
|
120
|
+
'& .MuiTypography-root': { fontSize: 11, lineHeight: 1.25 },
|
|
121
|
+
'& [data-testid^="stepper-step-content-"]': { minWidth: 0 },
|
|
122
|
+
'& [data-testid^="stepper-connector-"]': { minWidth: 6, mx: 0.75 },
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The lane, or nothing.
|
|
127
|
+
*
|
|
128
|
+
* `aria-hidden` AND `inert`, and each earns its place twice over. The stops are
|
|
129
|
+
* real buttons, so leaving four focusable, named controls per entry in front of
|
|
130
|
+
* an inbox would cost a keyboard user the list they opened the panel for —
|
|
131
|
+
* that is what the pair was added for. `inert` then turns out to be what makes
|
|
132
|
+
* the card's own hit area work as well, because an inert subtree is skipped by
|
|
133
|
+
* hit-testing: see {@link stretchedSx}.
|
|
134
|
+
*
|
|
135
|
+
* Nothing is lost by hiding it — the stop the subject is at is already the
|
|
136
|
+
* card's heading, and the row of dots restates it visually.
|
|
137
|
+
*/
|
|
138
|
+
function ActivityLane({ activity }: { activity: LiveActivity }): JSX.Element | null {
|
|
139
|
+
const lane = liveActivityLane(activity);
|
|
140
|
+
if (lane === null) return null;
|
|
141
|
+
return (
|
|
142
|
+
<Box sx={laneSx} aria-hidden inert>
|
|
143
|
+
<Stepper
|
|
144
|
+
steps={lane.steps.map((step) => ({ id: step.id, label: step.label }))}
|
|
145
|
+
activeId={lane.activeStepId}
|
|
146
|
+
completed={new Set(lane.completed)}
|
|
147
|
+
orientation="horizontal"
|
|
148
|
+
size="xs"
|
|
149
|
+
clickable={false}
|
|
150
|
+
data-testid={`live-activity-steps-${activity.id}`}
|
|
151
|
+
/>
|
|
152
|
+
</Box>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The card's props. Not part of the package's surface — see `./index`. */
|
|
157
|
+
interface LiveActivityCardProps {
|
|
158
|
+
activity: LiveActivity;
|
|
159
|
+
messages: NotificationMessages;
|
|
160
|
+
live: LiveActivityMessages;
|
|
161
|
+
renderIcon?: LiveActivitiesConfig['renderIcon'];
|
|
162
|
+
/** The clock this render reads, so the "last moved" line can be ticked. */
|
|
163
|
+
now: number;
|
|
164
|
+
/**
|
|
165
|
+
* Follow the card's link.
|
|
166
|
+
*
|
|
167
|
+
* Absent — as it is for a host with no router — renders the text as text. A
|
|
168
|
+
* named, focusable control that does nothing is worse than no control.
|
|
169
|
+
*/
|
|
170
|
+
onOpen?: (activity: LiveActivity) => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The mark on the left, when the host draws one.
|
|
175
|
+
*
|
|
176
|
+
* PRESENTATIONAL ONLY. It is rendered inside the card's `<button>` and inside
|
|
177
|
+
* an `aria-hidden` wrapper, so a host returning anything focusable — an
|
|
178
|
+
* icon-button, a link — puts a button inside a button (invalid HTML, and the
|
|
179
|
+
* defect this card was restructured to remove) and hides a focusable node from
|
|
180
|
+
* the accessibility tree. An icon, an emoji, an `<svg>`: yes. A control: no.
|
|
181
|
+
*/
|
|
182
|
+
function ActivityIcon({ icon }: { icon: ReactNode }): JSX.Element | null {
|
|
183
|
+
if (icon === undefined || icon === null) return null;
|
|
184
|
+
return (
|
|
185
|
+
<Box aria-hidden sx={{ display: 'flex', flex: '0 0 auto', color: 'primary.main' }}>
|
|
186
|
+
{icon}
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The mark, the heading and the line under it. */
|
|
192
|
+
function ActivityTarget({
|
|
193
|
+
activity,
|
|
194
|
+
renderIcon,
|
|
195
|
+
bodyId,
|
|
196
|
+
}: Pick<LiveActivityCardProps, 'activity' | 'renderIcon'> & {
|
|
197
|
+
/** Ties the sentence to the button, so a label does not swallow it. */
|
|
198
|
+
bodyId: string;
|
|
199
|
+
}): JSX.Element {
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
<ActivityIcon icon={renderIcon?.(activity)} />
|
|
203
|
+
{/* A COLUMN, not a bare block: `Text` sets no `display`, so two adjacent
|
|
204
|
+
spans in an ordinary div run together on one line with not even a
|
|
205
|
+
space between them — which is how the heading and the sentence under
|
|
206
|
+
it ended up as one word in an earlier draft. `row.tsx` gets this right
|
|
207
|
+
the same way. */}
|
|
208
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 0 }}>
|
|
209
|
+
{/*
|
|
210
|
+
The live region is THIS LINE and nothing else. The card also carries a
|
|
211
|
+
relative timestamp that moves every minute for as long as the subject
|
|
212
|
+
lasts, and announcing that is a polite interruption per minute for
|
|
213
|
+
news the reader did not ask to be read. What is worth interrupting for
|
|
214
|
+
is the subject MOVING, which is what the heading says.
|
|
215
|
+
*/}
|
|
216
|
+
<Text
|
|
217
|
+
variant="body"
|
|
218
|
+
size="sm"
|
|
219
|
+
weight="semibold"
|
|
220
|
+
as="span"
|
|
221
|
+
aria-live="polite"
|
|
222
|
+
data-testid={`live-activity-title-${activity.id}`}
|
|
223
|
+
>
|
|
224
|
+
{activity.title}
|
|
225
|
+
</Text>
|
|
226
|
+
{activity.body === null ? null : (
|
|
227
|
+
<Text id={bodyId} variant="caption" size="xs" color="secondary" as="span">
|
|
228
|
+
{activity.body}
|
|
229
|
+
</Text>
|
|
230
|
+
)}
|
|
231
|
+
</Box>
|
|
232
|
+
</>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function LiveActivityCard({
|
|
237
|
+
activity,
|
|
238
|
+
messages,
|
|
239
|
+
live,
|
|
240
|
+
renderIcon,
|
|
241
|
+
now,
|
|
242
|
+
onOpen,
|
|
243
|
+
}: LiveActivityCardProps): JSX.Element {
|
|
244
|
+
const followable = activity.link !== null && onOpen !== undefined;
|
|
245
|
+
const bodyId = useId();
|
|
246
|
+
const target = (
|
|
247
|
+
<ActivityTarget
|
|
248
|
+
activity={activity}
|
|
249
|
+
bodyId={bodyId}
|
|
250
|
+
{...(renderIcon ? { renderIcon } : {})}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
return (
|
|
254
|
+
<Box data-testid={`live-activity-${activity.id}`} sx={cardSx}>
|
|
255
|
+
{followable ? (
|
|
256
|
+
<Box
|
|
257
|
+
component="button"
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={() => onOpen(activity)}
|
|
260
|
+
// `aria-label` REPLACES the contents, so the sentence under the
|
|
261
|
+
// heading — the detail that makes the heading actionable — would be
|
|
262
|
+
// announced to nobody. `aria-describedby` puts it back.
|
|
263
|
+
aria-label={live.openActivity(activity.title)}
|
|
264
|
+
{...(activity.body === null ? {} : { 'aria-describedby': bodyId })}
|
|
265
|
+
data-testid={`live-activity-open-${activity.id}`}
|
|
266
|
+
sx={stretchedSx}
|
|
267
|
+
>
|
|
268
|
+
{target}
|
|
269
|
+
</Box>
|
|
270
|
+
) : (
|
|
271
|
+
<Box sx={targetSx}>{target}</Box>
|
|
272
|
+
)}
|
|
273
|
+
<ActivityLane activity={activity} />
|
|
274
|
+
<Text variant="caption" size="xs" color="secondary" as="span" italic>
|
|
275
|
+
{live.updated(relativeTime(activity.updatedAt, messages, now))}
|
|
276
|
+
</Text>
|
|
277
|
+
</Box>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's half of live activities: where they come from, and what they say.
|
|
3
|
+
*
|
|
4
|
+
* Both are the host's because neither can be this package's. It has no idea
|
|
5
|
+
* what is happening — only the application does — and it has no words, for the
|
|
6
|
+
* same reason `NotificationMessages` is required rather than defaulted: a
|
|
7
|
+
* package that ships one product's sentences ships them to every other product
|
|
8
|
+
* too, and the adopter reached by saying nothing is the one who never notices.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
|
|
12
|
+
import type { LiveActivity } from '../live';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the surface gets the activities that are live RIGHT NOW.
|
|
16
|
+
*
|
|
17
|
+
* A HOOK rather than a fetcher, and rather than a factory-time `subscribe`,
|
|
18
|
+
* because the answer almost always lives in React context: the tenant, the
|
|
19
|
+
* session, the host's query client. `NotificationsSignalHook` exists for
|
|
20
|
+
* exactly this reason one seam over, and a host in that shape had no way to
|
|
21
|
+
* pass anything at all.
|
|
22
|
+
*
|
|
23
|
+
* `active` is whether the surface currently needs the answer. It is a HINT
|
|
24
|
+
* about need, never about correctness: a host that ignores it and always
|
|
25
|
+
* answers is behaving correctly and merely paying for it.
|
|
26
|
+
*
|
|
27
|
+
* What it is NOT is the only thing standing between a shut panel and a query.
|
|
28
|
+
* The panel is fetched lazily and renders nothing until somebody first opens
|
|
29
|
+
* the bell, and the drawer unmounts its content on close — so a host that
|
|
30
|
+
* simply reads `active` and ignores it still issues nothing while the panel is
|
|
31
|
+
* away. `active` is `false` for the frames of the closing transition, which is
|
|
32
|
+
* where it earns its keep: a query told to stand down there does not fire one
|
|
33
|
+
* last time on the way out.
|
|
34
|
+
*
|
|
35
|
+
* Return whatever is live, newest activity first or in whatever order the host
|
|
36
|
+
* means; the surface renders them in the order given. An empty array is the
|
|
37
|
+
* normal answer and renders nothing — no heading, no empty state, no gap.
|
|
38
|
+
*/
|
|
39
|
+
export type LiveActivitiesHook = (options: {
|
|
40
|
+
active: boolean;
|
|
41
|
+
}) => readonly LiveActivity[];
|
|
42
|
+
|
|
43
|
+
/** The three sentences the live section says. */
|
|
44
|
+
export interface LiveActivityMessages {
|
|
45
|
+
/** The heading over the pinned entries, e.g. "Em andamento". */
|
|
46
|
+
sectionTitle: string;
|
|
47
|
+
/** The link's accessible name, e.g. `(title) => `Abrir ${title}``. */
|
|
48
|
+
openActivity: (title: string) => string;
|
|
49
|
+
/**
|
|
50
|
+
* The "last moved" line, given an already-relative time.
|
|
51
|
+
*
|
|
52
|
+
* Takes the phrase rather than the instant so the relative wording stays in
|
|
53
|
+
* ONE place — `relativeTime` and the inbox rows' `há 5 min` — and a host
|
|
54
|
+
* cannot end up with two vocabularies for the same duration in one panel.
|
|
55
|
+
*/
|
|
56
|
+
updated: (relative: string) => string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Live activities, as a host turns them on. */
|
|
60
|
+
export interface LiveActivitiesConfig {
|
|
61
|
+
useActivities: LiveActivitiesHook;
|
|
62
|
+
messages: LiveActivityMessages;
|
|
63
|
+
/**
|
|
64
|
+
* The mark on the left of a card — the fastest read, before any words.
|
|
65
|
+
*
|
|
66
|
+
* A node rather than a field on {@link LiveActivity} so the contract stays
|
|
67
|
+
* framework-free: the root entry is shared with the server half, and a
|
|
68
|
+
* `ReactNode` in it would put React on that import path for a backend that
|
|
69
|
+
* only ever writes rows. The host switches on `kind`, which is what `kind` is
|
|
70
|
+
* for. No renderer, no mark, and the card is text — never a placeholder box.
|
|
71
|
+
*
|
|
72
|
+
* **Return something PRESENTATIONAL.** The mark is drawn inside the card's
|
|
73
|
+
* own `<button>` and inside an `aria-hidden` wrapper, so a focusable node
|
|
74
|
+
* here is a button inside a button — invalid HTML, and the exact defect the
|
|
75
|
+
* card's structure exists to prevent — as well as a control hidden from the
|
|
76
|
+
* accessibility tree. An icon or an `<svg>`; not a control.
|
|
77
|
+
*/
|
|
78
|
+
renderIcon?: (activity: LiveActivity) => ReactNode;
|
|
79
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned block at the top of the panel: everything that is happening NOW,
|
|
3
|
+
* above everything that has already happened.
|
|
4
|
+
*
|
|
5
|
+
* ## Why it is here and not a second surface
|
|
6
|
+
*
|
|
7
|
+
* The notification centre is where a person goes to find out what they missed.
|
|
8
|
+
* Splitting "happening" into its own bell would make them check two places to
|
|
9
|
+
* answer one question, and the half they would stop checking is the one that
|
|
10
|
+
* only has something in it occasionally — which is this one. Above the list,
|
|
11
|
+
* inside the same drawer, it is on the path they already walk.
|
|
12
|
+
*
|
|
13
|
+
* ## What it deliberately does NOT do
|
|
14
|
+
*
|
|
15
|
+
* - It does not touch `unread`. A live entry is not news; counting it would put
|
|
16
|
+
* a number on the bell that no amount of reading can clear.
|
|
17
|
+
* - It renders nothing at all when there is nothing live — no heading, no empty
|
|
18
|
+
* state, no reserved space. A panel with one permanent empty section in it is
|
|
19
|
+
* a panel that has taught its reader to skip the top.
|
|
20
|
+
* - It does not fetch. `useActivities` is the host's, and `active` tells it
|
|
21
|
+
* whether anyone is looking.
|
|
22
|
+
*/
|
|
23
|
+
import { useEffect, useId, useState, type JSX, type ReactNode } from 'react';
|
|
24
|
+
|
|
25
|
+
import { Box } from '@12-apps/ui/mui/Box';
|
|
26
|
+
import { Text } from '@12-apps/ui/typography/Text';
|
|
27
|
+
|
|
28
|
+
import type { LiveActivity } from '../live';
|
|
29
|
+
import type { NotificationMessages } from '../messages';
|
|
30
|
+
|
|
31
|
+
import { LiveActivityCard } from './live-card';
|
|
32
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
33
|
+
import type { LiveSeenStore } from './live-seen';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How often the section re-reads the clock.
|
|
37
|
+
*
|
|
38
|
+
* Every minute, because the timestamps under the cards are in minutes and a
|
|
39
|
+
* tick that cannot change what is on screen is a wasted render — which is why
|
|
40
|
+
* it is gated on there being something to tick as well as on the panel being
|
|
41
|
+
* open. An open panel with nothing live schedules nothing at all; the earlier
|
|
42
|
+
* gate was `active` alone, and it re-rendered a section that renders `null`
|
|
43
|
+
* once a minute for as long as somebody left the inbox open.
|
|
44
|
+
*/
|
|
45
|
+
const TICK_MS = 60_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The current minute, re-read on a timer while there is something to tick.
|
|
49
|
+
*
|
|
50
|
+
* The caller passes `active && there are activities` — see {@link TICK_MS} for
|
|
51
|
+
* why both halves are in it.
|
|
52
|
+
*/
|
|
53
|
+
function useMinuteTick(active: boolean): number {
|
|
54
|
+
const [now, setNow] = useState(() => Date.now());
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!active) return;
|
|
57
|
+
// Re-read once on becoming active too: a panel reopened after ten minutes
|
|
58
|
+
// would otherwise show the minute it was closed at until the first tick.
|
|
59
|
+
setNow(Date.now());
|
|
60
|
+
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
|
|
61
|
+
return () => clearInterval(timer);
|
|
62
|
+
}, [active]);
|
|
63
|
+
return now;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LiveSectionProps {
|
|
67
|
+
config: LiveActivitiesConfig;
|
|
68
|
+
messages: NotificationMessages;
|
|
69
|
+
/** Whether the panel is open — passed straight through to the host's hook. */
|
|
70
|
+
active: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Follow a card's link.
|
|
73
|
+
*
|
|
74
|
+
* Optional, and the panel omits it for a host with no router: a card that
|
|
75
|
+
* cannot go anywhere renders as text rather than as a named control that
|
|
76
|
+
* does nothing.
|
|
77
|
+
*/
|
|
78
|
+
onOpen?: (activity: LiveActivity) => void;
|
|
79
|
+
/**
|
|
80
|
+
* The rest of the panel, given how many entries are live.
|
|
81
|
+
*
|
|
82
|
+
* A render prop rather than a sibling, because the count is knowable only
|
|
83
|
+
* where the host's hook is CALLED, and it cannot be called anywhere else:
|
|
84
|
+
* `live` is optional on the panel, so reading it there would mean calling a
|
|
85
|
+
* hook conditionally — the failure React reports as a crash in some unrelated
|
|
86
|
+
* component.
|
|
87
|
+
*
|
|
88
|
+
* The inbox needs the number for exactly one decision, and it is the decision
|
|
89
|
+
* this section exists to inform: whether "no notifications" is true. A live
|
|
90
|
+
* entry IS a notification, so a panel showing one under that sentence is
|
|
91
|
+
* contradicting itself.
|
|
92
|
+
*/
|
|
93
|
+
children?: (liveCount: number) => ReactNode;
|
|
94
|
+
/**
|
|
95
|
+
* Where "the reader has seen these" is recorded, for the bell to read.
|
|
96
|
+
*
|
|
97
|
+
* Written HERE because this is the component that puts them on screen, and
|
|
98
|
+
* being on screen is what seen means. Optional so the section stays usable by
|
|
99
|
+
* a host that mounts it outside the panel.
|
|
100
|
+
*/
|
|
101
|
+
seen?: LiveSeenStore;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
export function LiveSection({
|
|
107
|
+
config,
|
|
108
|
+
messages,
|
|
109
|
+
active,
|
|
110
|
+
onOpen,
|
|
111
|
+
children,
|
|
112
|
+
seen,
|
|
113
|
+
}: LiveSectionProps): JSX.Element {
|
|
114
|
+
// Unconditional, because it is a hook. `active` is how it is told nobody is
|
|
115
|
+
// looking — the same arrangement `useSignal` has one seam over.
|
|
116
|
+
const activities = config.useActivities({ active });
|
|
117
|
+
const now = useMinuteTick(active && activities.length > 0);
|
|
118
|
+
// Per MOUNT, not per module: `LiveSection` is exported, and a host with a
|
|
119
|
+
// desktop and a mobile panel would otherwise emit one id twice and have both
|
|
120
|
+
// regions resolve their label to whichever came first.
|
|
121
|
+
const headingId = useId();
|
|
122
|
+
|
|
123
|
+
const liveCount = activities.length;
|
|
124
|
+
|
|
125
|
+
// Only while somebody is looking. The panel keeps this mounted through the
|
|
126
|
+
// closing transition, and marking there would swallow an update that arrived
|
|
127
|
+
// in the frames after the reader turned away.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (active && liveCount > 0) seen?.mark(activities);
|
|
130
|
+
}, [active, liveCount, activities, seen]);
|
|
131
|
+
|
|
132
|
+
if (liveCount === 0) return <>{children?.(0)}</>;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
// A NAMED region. Without the label a screen-reader user meets a loose run
|
|
137
|
+
// of controls ahead of the inbox with nothing saying what they are; the
|
|
138
|
+
// panel's own title is the drawer's heading and cannot describe this block.
|
|
139
|
+
<Box
|
|
140
|
+
component="section"
|
|
141
|
+
aria-labelledby={headingId}
|
|
142
|
+
data-testid="live-activities"
|
|
143
|
+
sx={{ pb: 1.5 }}
|
|
144
|
+
>
|
|
145
|
+
{/*
|
|
146
|
+
A SPAN, not a heading. `aria-labelledby` names the region perfectly well
|
|
147
|
+
from one, and an `<h2>` here would sit under the drawer's own `<h6>`
|
|
148
|
+
title and ABOVE the inbox's `<h3>` empty state — an outline in which the
|
|
149
|
+
inbox's states read as part of the live block, which is the opposite of
|
|
150
|
+
what the two blocks are.
|
|
151
|
+
*/}
|
|
152
|
+
<Text
|
|
153
|
+
id={headingId}
|
|
154
|
+
variant="caption"
|
|
155
|
+
size="xs"
|
|
156
|
+
color="secondary"
|
|
157
|
+
weight="semibold"
|
|
158
|
+
as="span"
|
|
159
|
+
>
|
|
160
|
+
{config.messages.sectionTitle}
|
|
161
|
+
</Text>
|
|
162
|
+
<Box sx={{ pt: 0.75 }}>
|
|
163
|
+
{activities.map((activity) => (
|
|
164
|
+
<LiveActivityCard
|
|
165
|
+
key={activity.id}
|
|
166
|
+
activity={activity}
|
|
167
|
+
messages={messages}
|
|
168
|
+
live={config.messages}
|
|
169
|
+
now={now}
|
|
170
|
+
{...(onOpen ? { onOpen } : {})}
|
|
171
|
+
{...(config.renderIcon ? { renderIcon: config.renderIcon } : {})}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</Box>
|
|
175
|
+
</Box>
|
|
176
|
+
{children?.(liveCount)}
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reader has already been shown, so the bell can say NEW rather than
|
|
3
|
+
* merely PRESENT.
|
|
4
|
+
*
|
|
5
|
+
* A live activity is unlike an inbox row in the one way that matters here: it
|
|
6
|
+
* stays on the panel for as long as the thing is happening, so its presence
|
|
7
|
+
* cannot mean "you have not seen this". A pedido that has been `Preparo` for
|
|
8
|
+
* ten minutes is still live and still worth counting, but nothing has happened
|
|
9
|
+
* — and a badge that shouts for a subject the reader has already looked at is a
|
|
10
|
+
* badge people stop reading.
|
|
11
|
+
*
|
|
12
|
+
* So presence and novelty are answered separately: the COUNT comes from how
|
|
13
|
+
* many are live, and the TONE comes from this. The panel writes it — being on
|
|
14
|
+
* screen is what seen means — and the bell reads it.
|
|
15
|
+
*
|
|
16
|
+
* ## Per subject, not one watermark
|
|
17
|
+
*
|
|
18
|
+
* A single "newest instant already seen" is smaller and was the first cut, and
|
|
19
|
+
* it is wrong in a way that shows up in normal use: a pedido placed ten minutes
|
|
20
|
+
* ago but only now reaching the client arrives with an `updatedAt` BEHIND the
|
|
21
|
+
* watermark, and would be silently marked as already seen. The reader has never
|
|
22
|
+
* laid eyes on it. Keyed by subject, an id that has not been recorded is new
|
|
23
|
+
* whatever its clock says.
|
|
24
|
+
*
|
|
25
|
+
* Bounded by pruning rather than by expiry: every write keeps only the subjects
|
|
26
|
+
* that are live at that moment, so the record can never outgrow the number of
|
|
27
|
+
* things happening at once. A subject that finishes and later comes back is
|
|
28
|
+
* news again, which is correct — it is a different occurrence.
|
|
29
|
+
*/
|
|
30
|
+
import type { LiveActivity } from '../live';
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = '12a.notifications.live-seen';
|
|
33
|
+
|
|
34
|
+
/** id -> the `updatedAt` that was on screen. */
|
|
35
|
+
type SeenMap = Readonly<Record<string, string>>;
|
|
36
|
+
|
|
37
|
+
const EMPTY: SeenMap = {};
|
|
38
|
+
|
|
39
|
+
/** ms since epoch, or `null` for an absent or unparseable stamp. */
|
|
40
|
+
function instant(iso: string | undefined): number | null {
|
|
41
|
+
if (iso === undefined) return null;
|
|
42
|
+
const ms = Date.parse(iso);
|
|
43
|
+
return Number.isNaN(ms) ? null : ms;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read/write through `try`, every time.
|
|
48
|
+
*
|
|
49
|
+
* `localStorage` is not merely absent in SSR and in a worker — the ACCESSOR
|
|
50
|
+
* itself throws in a browser set to block site data. A notification bell that
|
|
51
|
+
* cannot render because storage is blocked is a worse failure than one that
|
|
52
|
+
* forgets what was seen, and forgetting degrades in the safe direction: towards
|
|
53
|
+
* saying something is happening.
|
|
54
|
+
*/
|
|
55
|
+
function readStored(): SeenMap {
|
|
56
|
+
try {
|
|
57
|
+
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
|
|
58
|
+
if (!raw) return EMPTY;
|
|
59
|
+
const parsed: unknown = JSON.parse(raw);
|
|
60
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return EMPTY;
|
|
61
|
+
// Anything can be in storage — another version of this package, or a person
|
|
62
|
+
// with the devtools open. Keep only what has the shape this reads.
|
|
63
|
+
const clean: Record<string, string> = {};
|
|
64
|
+
for (const [id, value] of Object.entries(parsed)) {
|
|
65
|
+
if (typeof value === 'string') clean[id] = value;
|
|
66
|
+
}
|
|
67
|
+
return clean;
|
|
68
|
+
} catch {
|
|
69
|
+
return EMPTY;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeStored(value: SeenMap): void {
|
|
74
|
+
try {
|
|
75
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value));
|
|
76
|
+
} catch {
|
|
77
|
+
// Blocked or full. The badge stays new a while longer; nothing else breaks.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface LiveSeenStore {
|
|
82
|
+
/** What has been shown, keyed by subject id. */
|
|
83
|
+
read: () => SeenMap;
|
|
84
|
+
/** Record that exactly these are on screen now, forgetting subjects that are not. */
|
|
85
|
+
mark: (activities: readonly LiveActivity[]) => void;
|
|
86
|
+
subscribe: (listener: () => void) => () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createLiveSeenStore(): LiveSeenStore {
|
|
90
|
+
// Mirrored in memory as well as in storage: `useSyncExternalStore` compares
|
|
91
|
+
// snapshots by IDENTITY and calls `read` on every render, so parsing storage
|
|
92
|
+
// there would hand it a fresh object each time and re-render for ever.
|
|
93
|
+
let current = readStored();
|
|
94
|
+
const listeners = new Set<() => void>();
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
read: () => current,
|
|
98
|
+
mark: (activities) => {
|
|
99
|
+
const next: Record<string, string> = {};
|
|
100
|
+
for (const activity of activities) next[activity.id] = activity.updatedAt;
|
|
101
|
+
// Identity is the snapshot, so an unchanged map must not become a new
|
|
102
|
+
// object — see `read` above.
|
|
103
|
+
const ids = Object.keys(next);
|
|
104
|
+
const same =
|
|
105
|
+
ids.length === Object.keys(current).length &&
|
|
106
|
+
ids.every((id) => current[id] === next[id]);
|
|
107
|
+
if (same) return;
|
|
108
|
+
current = next;
|
|
109
|
+
writeStored(next);
|
|
110
|
+
for (const listener of listeners) listener();
|
|
111
|
+
},
|
|
112
|
+
subscribe: (listener) => {
|
|
113
|
+
listeners.add(listener);
|
|
114
|
+
return () => {
|
|
115
|
+
listeners.delete(listener);
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether any of these has moved, or arrived, since the reader last looked.
|
|
123
|
+
*
|
|
124
|
+
* An id with nothing recorded is new — that is the case the per-subject record
|
|
125
|
+
* exists for. An unparseable stamp is treated as new too: the alternative is
|
|
126
|
+
* silently never alerting for a host whose clock format this does not read.
|
|
127
|
+
*/
|
|
128
|
+
export function hasUnseenActivity(
|
|
129
|
+
activities: readonly LiveActivity[],
|
|
130
|
+
seen: SeenMap,
|
|
131
|
+
): boolean {
|
|
132
|
+
return activities.some((activity) => {
|
|
133
|
+
const shown = instant(seen[activity.id]);
|
|
134
|
+
if (shown === null) return true;
|
|
135
|
+
const now = instant(activity.updatedAt);
|
|
136
|
+
return now === null || now > shown;
|
|
137
|
+
});
|
|
138
|
+
}
|