@12-apps/notifications 4.8.0 → 4.10.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/ADOPTING.md +173 -1
- package/README.md +138 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
- package/dist/chunk-5Y7QRORV.js.map +1 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/chunk-EKUSNUBT.js +222 -0
- package/dist/chunk-EKUSNUBT.js.map +1 -0
- package/dist/chunk-FTFLU6KH.js +114 -0
- package/dist/chunk-FTFLU6KH.js.map +1 -0
- package/dist/chunk-JCVRQ42B.js +226 -0
- package/dist/chunk-JCVRQ42B.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-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
- package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
- package/dist/email/index.d.ts +26 -0
- package/dist/email/index.js +20 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/locales.d.ts +43 -0
- package/dist/email/locales.js +29 -0
- package/dist/email/locales.js.map +1 -0
- package/dist/email/previews/hono.d.ts +46 -0
- package/dist/email/previews/hono.js +33 -0
- package/dist/email/previews/hono.js.map +1 -0
- package/dist/email/previews/index.d.ts +67 -0
- package/dist/email/previews/index.js +12 -0
- package/dist/email/previews/index.js.map +1 -0
- package/dist/email/previews/react/index.d.ts +43 -0
- package/dist/email/previews/react/index.js +58 -0
- package/dist/email/previews/react/index.js.map +1 -0
- package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +5 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +15 -5
- package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/index.d.ts +39 -1
- package/dist/manifest/index.js +9 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +32 -7
- package/dist/manifest/server.js +16 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +19 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
- package/dist/panel-T36JEMO3.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +55 -6
- package/dist/react/index.js +16 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +6 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +22 -2
- package/src/email/chrome.en-US.ts +8 -0
- package/src/email/chrome.pt-BR.ts +21 -0
- package/src/email/index.ts +43 -0
- package/src/email/locales.ts +29 -0
- package/src/email/previews/catalog.ts +208 -0
- package/src/email/previews/hono.ts +69 -0
- package/src/email/previews/index.ts +26 -0
- package/src/email/previews/react/copy.en-US.ts +24 -0
- package/src/email/previews/react/copy.pt-BR.ts +24 -0
- package/src/email/previews/react/copy.ts +42 -0
- package/src/email/previews/react/index.tsx +14 -0
- package/src/email/previews/react/loadable.tsx +83 -0
- package/src/email/previews/react/message-list.tsx +133 -0
- package/src/email/previews/react/message-view.tsx +167 -0
- package/src/email/previews/react/preview-screen.tsx +342 -0
- package/src/email/previews/react/transport.ts +60 -0
- package/src/email/previews/routes.ts +111 -0
- package/src/email/template.ts +365 -0
- package/src/email/text.ts +59 -0
- package/src/email/theme.ts +77 -0
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/react/create-web-notifications.tsx +15 -1
- 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 +141 -0
- package/src/react/panel-lazy.tsx +3 -0
- package/src/react/panel.tsx +57 -10
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/email.ts +62 -0
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-7234ROOP.js.map +0 -1
- package/dist/chunk-BGI4BQ66.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
package/src/live.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE ACTIVITIES — the notification centre's second kind of entry.
|
|
3
|
+
*
|
|
4
|
+
* An inbox notification is an EVENT: it happened, it is stamped, it is read or
|
|
5
|
+
* unread, and it is still there tomorrow saying the same thing. A live activity
|
|
6
|
+
* is ONGOING STATE: it is pinned above the list, it has no read/unread, nobody
|
|
7
|
+
* deletes it, it updates itself, and it is GONE the moment the thing it tracks
|
|
8
|
+
* finishes.
|
|
9
|
+
*
|
|
10
|
+
* The distinction is not cosmetic. A row that says "your order is on its way",
|
|
11
|
+
* read an hour later, is a claim about the past presented as news — and the
|
|
12
|
+
* more reliable the inbox is, the more of those a person accumulates. Somewhere
|
|
13
|
+
* in that list is the one question they actually have, which is *where is it
|
|
14
|
+
* now*. A live activity answers that question and then removes itself, which is
|
|
15
|
+
* the property no amount of better event copy can buy.
|
|
16
|
+
*
|
|
17
|
+
* ## This file is domain-free, and that is the whole design
|
|
18
|
+
*
|
|
19
|
+
* Nothing here knows what is being tracked. A host publishes activities through
|
|
20
|
+
* `LiveActivitiesConfig.useActivities` (see `./react/live-config`) and this
|
|
21
|
+
* package owns the contract, the placement, the rendering and the OS-level
|
|
22
|
+
* collapse. The package that raises an alert has never been this package's
|
|
23
|
+
* business — see the `generators` seam — and the thing an alert is ABOUT is not
|
|
24
|
+
* either.
|
|
25
|
+
*
|
|
26
|
+
* ## The lane
|
|
27
|
+
*
|
|
28
|
+
* `steps` + `activeStepId` describe a progress lane, because "how far along is
|
|
29
|
+
* this" is the shape almost every ongoing subject has. Both are optional in
|
|
30
|
+
* effect: an activity with no lane (`steps: []`, `activeStepId: null`) is a
|
|
31
|
+
* perfectly good live entry — a heading, a sentence and a timestamp that keeps
|
|
32
|
+
* moving.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** One stop on a live activity's lane. */
|
|
36
|
+
export interface LiveActivityStep {
|
|
37
|
+
/** Stable across updates — the id is what `activeStepId` names. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** What the reader sees under the dot. The HOST's word, in the reader's language. */
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One thing that is happening right now, as the notification centre shows it. */
|
|
44
|
+
export interface LiveActivity {
|
|
45
|
+
/**
|
|
46
|
+
* Stable for the whole life of the subject.
|
|
47
|
+
*
|
|
48
|
+
* The SAME id on every update, because it is the identity of the thing being
|
|
49
|
+
* tracked and not of the message: the panel keys on it so a stage change
|
|
50
|
+
* re-renders one card rather than swapping two, and it is what
|
|
51
|
+
* {@link livePushTag} collapses an OS notification onto.
|
|
52
|
+
*
|
|
53
|
+
* **Unique among the activities live at one moment**, for the same reason:
|
|
54
|
+
* it is the React key and the card's test id. Two activities sharing one
|
|
55
|
+
* gives a duplicate-key warning and a card that silently shows the wrong
|
|
56
|
+
* subject.
|
|
57
|
+
*/
|
|
58
|
+
id: string;
|
|
59
|
+
/**
|
|
60
|
+
* Which host concern this belongs to, e.g. `order`, `delivery`, `import`.
|
|
61
|
+
*
|
|
62
|
+
* Free-form and never rendered — it exists so a host publishing from two
|
|
63
|
+
* sources can tell its own activities apart in a test or a log without
|
|
64
|
+
* parsing `id`.
|
|
65
|
+
*/
|
|
66
|
+
kind: string;
|
|
67
|
+
/** The heading — what is happening. */
|
|
68
|
+
title: string;
|
|
69
|
+
/** The line under it: the detail that makes the heading actionable, or none. */
|
|
70
|
+
body: string | null;
|
|
71
|
+
/** Where tapping the card goes, as a same-origin path. `null` renders no link. */
|
|
72
|
+
link: string | null;
|
|
73
|
+
/** The lane, in the order it is walked. Empty when this subject has no lane. */
|
|
74
|
+
steps: readonly LiveActivityStep[];
|
|
75
|
+
/**
|
|
76
|
+
* The stop the subject is AT — one of `steps`, or `null` for a laneless entry.
|
|
77
|
+
*
|
|
78
|
+
* An id that names no step is neither, and it is handled rather than trusted:
|
|
79
|
+
* see {@link liveActivityLane}.
|
|
80
|
+
*/
|
|
81
|
+
activeStepId: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* ISO-8601 — when the subject last MOVED, not when it was last polled.
|
|
84
|
+
*
|
|
85
|
+
* A timestamp that advances on every read would render "just now" forever,
|
|
86
|
+
* which is the one thing a live entry must not say when nothing is happening.
|
|
87
|
+
*/
|
|
88
|
+
updatedAt: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A live activity's lane, resolved for rendering. */
|
|
92
|
+
export interface LiveActivityLane {
|
|
93
|
+
steps: readonly LiveActivityStep[];
|
|
94
|
+
activeStepId: string;
|
|
95
|
+
/** Every stop BEFORE the active one — never the active one itself. */
|
|
96
|
+
completed: ReadonlySet<string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The lane to draw, or `null` when this activity has none to draw.
|
|
101
|
+
*
|
|
102
|
+
* Three cases collapse to `null`, and the third is the one worth writing down:
|
|
103
|
+
* no steps, no active step, and **an active step the lane does not contain**.
|
|
104
|
+
* That last one is not a theoretical defect — it is what a host produces the
|
|
105
|
+
* first time a subject reaches a stage the lane was filtered to exclude, and
|
|
106
|
+
* the symptom is a row of dots with NONE of them lit, which reads as a process
|
|
107
|
+
* that has stopped. A card with no lane still says what is happening and when
|
|
108
|
+
* it last moved; a dead lane says the tracking is broken.
|
|
109
|
+
*
|
|
110
|
+
* `completed` deliberately stops short of the active stop: a completed step
|
|
111
|
+
* draws a tick, and ticking the stop the subject is sitting in claims it has
|
|
112
|
+
* already left.
|
|
113
|
+
*/
|
|
114
|
+
export function liveActivityLane(activity: LiveActivity): LiveActivityLane | null {
|
|
115
|
+
const activeStepId = activity.activeStepId;
|
|
116
|
+
if (activeStepId === null || activity.steps.length === 0) return null;
|
|
117
|
+
const reached = activity.steps.findIndex((step) => step.id === activeStepId);
|
|
118
|
+
if (reached < 0) return null;
|
|
119
|
+
return {
|
|
120
|
+
steps: activity.steps,
|
|
121
|
+
activeStepId,
|
|
122
|
+
completed: new Set(activity.steps.slice(0, reached).map((step) => step.id)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The reserved `data` key that ties a NOTIFICATION to a live subject.
|
|
128
|
+
*
|
|
129
|
+
* A notification and a live activity are different objects with different
|
|
130
|
+
* lifetimes, and this is the only thing that joins them: a generator whose
|
|
131
|
+
* event is about something also tracked live puts the activity's id here, and
|
|
132
|
+
* every channel that can collapse gets to.
|
|
133
|
+
*
|
|
134
|
+
* data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
|
|
135
|
+
*
|
|
136
|
+
* A plain string on purpose. An object here would grow a second wire contract
|
|
137
|
+
* inside a column this package stores verbatim.
|
|
138
|
+
*/
|
|
139
|
+
export const LIVE_SUBJECT_KEY = 'liveSubject';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Namespaces the tray tag, so a live tag does not collide with the ones a host
|
|
143
|
+
* already uses.
|
|
144
|
+
*
|
|
145
|
+
* A convention rather than an enforcement — nothing stops a host emitting its
|
|
146
|
+
* own `live:`-prefixed tags — but it means the two id spaces have to be made to
|
|
147
|
+
* meet rather than meeting by accident.
|
|
148
|
+
*/
|
|
149
|
+
export const LIVE_PUSH_TAG_PREFIX = 'live:';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The OS notification tag for a push about a live subject, or `null` for an
|
|
153
|
+
* ordinary event.
|
|
154
|
+
*
|
|
155
|
+
* ## What the tag buys, and why it is the PWA half of this feature
|
|
156
|
+
*
|
|
157
|
+
* A `tag` makes a new notification REPLACE the one already in the tray instead
|
|
158
|
+
* of stacking under it, and — unless the sender asks otherwise — replace it
|
|
159
|
+
* *silently*. So a subject that moves through four stages costs one tray entry
|
|
160
|
+
* and one buzz, and the entry that remains is the CURRENT one. Without it a
|
|
161
|
+
* phone accumulates one alert per stage, all of them still asserting a stage
|
|
162
|
+
* the subject has since left; the freshest is at the top and the reader has to
|
|
163
|
+
* work out that the three below it are history.
|
|
164
|
+
*
|
|
165
|
+
* That is as close as the web platform gets to an ongoing/live notification,
|
|
166
|
+
* and it is the half that reaches a person who does not have the app open —
|
|
167
|
+
* which is most of the time a live activity is live.
|
|
168
|
+
*
|
|
169
|
+
* The id is passed through unchanged: it is the host's, it is already unique
|
|
170
|
+
* per subject, and a hash would make the tray impossible to reason about from a
|
|
171
|
+
* log line.
|
|
172
|
+
*/
|
|
173
|
+
export function livePushTag(
|
|
174
|
+
data: Readonly<Record<string, unknown>> | null | undefined,
|
|
175
|
+
): string | null {
|
|
176
|
+
const subject = data?.[LIVE_SUBJECT_KEY];
|
|
177
|
+
if (typeof subject !== 'string' || subject === '') return null;
|
|
178
|
+
return `${LIVE_PUSH_TAG_PREFIX}${subject}`;
|
|
179
|
+
}
|
package/src/manifest/index.ts
CHANGED
|
@@ -61,3 +61,40 @@ export const notificationsManifest = {
|
|
|
61
61
|
server: ['http', 'jobs'],
|
|
62
62
|
web: ['surface'],
|
|
63
63
|
} as const satisfies PackageManifest;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The e-mail PREVIEW console — a second manifest, from the same package.
|
|
67
|
+
*
|
|
68
|
+
* ## Why a second manifest and not two more keys on the first
|
|
69
|
+
*
|
|
70
|
+
* `notificationsManifest` has already spent both slots this surface needs:
|
|
71
|
+
* `http` is the account inbox at `/api/account`, `surface` is the bell and the
|
|
72
|
+
* preference matrix. A capability is singular by the contract's shape, so a
|
|
73
|
+
* package with two genuinely different surfaces declares two manifests — which
|
|
74
|
+
* is exactly what `@12-apps/auth` does with `@12-apps/auth-platform`, and for
|
|
75
|
+
* the same reason it gives: those two switches turn a sign-in method off for
|
|
76
|
+
* EVERYBODY, so they do not belong behind the same gate as "reset my password".
|
|
77
|
+
*
|
|
78
|
+
* The split here is the same one. The inbox ships to every signed-in user; this
|
|
79
|
+
* console publishes the product's whole transactional-mail inventory and the
|
|
80
|
+
* exact wording and link shape of its verification and reset mails, which is
|
|
81
|
+
* the reference someone writing a convincing phishing mail would want. Two
|
|
82
|
+
* manifests keep that expressible: a host mounts the inbox and DECLINES the
|
|
83
|
+
* console, in writing, rather than silently getting both behind one gate.
|
|
84
|
+
*
|
|
85
|
+
* ## What it deliberately does not declare
|
|
86
|
+
*
|
|
87
|
+
* No `db` — a catalogue is DERIVED from what a host already sends, so an
|
|
88
|
+
* adopter mounts it without touching its schema. No `notifications`, because
|
|
89
|
+
* this surface authors no alert. And no `email`: that capability is a DELIVERY
|
|
90
|
+
* port, and this manifest renders and never sends. The delivery port is the
|
|
91
|
+
* OTHER half of this package, and it stays where it is.
|
|
92
|
+
*/
|
|
93
|
+
export const notificationEmailPreviewsManifest = {
|
|
94
|
+
name: '@12-apps/notifications-email-previews',
|
|
95
|
+
contract: 1,
|
|
96
|
+
/** A refusal to render a preview files under its own namespace, not nowhere. */
|
|
97
|
+
observability: { namespace: 'email-previews' },
|
|
98
|
+
server: ['http'],
|
|
99
|
+
web: ['surface'],
|
|
100
|
+
} as const satisfies PackageManifest;
|
package/src/manifest/server.ts
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import type { AnyServerManifest, WireRequest } from '@12-apps/wiring';
|
|
27
27
|
|
|
28
|
+
import { emailPreviewRoutes } from '../email/previews/routes';
|
|
29
|
+
import type { EmailPreviewsConfig } from '../email/previews/catalog';
|
|
28
30
|
import {
|
|
29
31
|
createApiNotifications,
|
|
30
32
|
NOTIFICATIONS_JOBS,
|
|
@@ -84,3 +86,24 @@ export const notificationsServerManifest = {
|
|
|
84
86
|
*/
|
|
85
87
|
jobs: NOTIFICATIONS_JOBS,
|
|
86
88
|
} as const satisfies AnyServerManifest;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The preview console's server half — the two endpoints over the catalogue.
|
|
92
|
+
*
|
|
93
|
+
* A CONSTANT, not a factory. `http.create(config)` receives whatever the host
|
|
94
|
+
* bound at adoption, and "which messages exist" is precisely a host's binding:
|
|
95
|
+
* a package cannot know that a product sends a "your quota is exhausted"
|
|
96
|
+
* notice, let alone what data it renders from. Writing `EmailPreviewsConfig`
|
|
97
|
+
* as a factory argument instead would move that decision out of
|
|
98
|
+
* `bindings.http`, where `assemble()` can report on it, into a call the report
|
|
99
|
+
* never sees.
|
|
100
|
+
*
|
|
101
|
+
* The routes carry `kind: 'authenticated'` — see `../email/previews/routes`
|
|
102
|
+
* for why the descriptor states a posture it cannot itself enforce.
|
|
103
|
+
*/
|
|
104
|
+
export const notificationEmailPreviewsServerManifest = {
|
|
105
|
+
name: '@12-apps/notifications-email-previews',
|
|
106
|
+
http: {
|
|
107
|
+
create: (config: EmailPreviewsConfig) => ({ routes: emailPreviewRoutes(config) }),
|
|
108
|
+
},
|
|
109
|
+
} as const satisfies AnyServerManifest;
|
package/src/manifest/web.ts
CHANGED
|
@@ -38,9 +38,22 @@
|
|
|
38
38
|
|
|
39
39
|
import type { AnyWebManifest } from '@12-apps/wiring';
|
|
40
40
|
|
|
41
|
+
import { createEmailPreviewScreen } from '../email/previews/react/preview-screen';
|
|
41
42
|
import { createWebNotifications } from '../react/create-web-notifications';
|
|
42
43
|
|
|
43
44
|
export const notificationsWebManifest = {
|
|
44
45
|
name: '@12-apps/notifications',
|
|
45
46
|
surface: { create: createWebNotifications },
|
|
46
47
|
} as const satisfies AnyWebManifest;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The preview console's web half.
|
|
51
|
+
*
|
|
52
|
+
* One screen, named `page`, because a screen's NAME is what an area row
|
|
53
|
+
* resolves against — a surface that was itself the component is the shape that
|
|
54
|
+
* made an area row resolve to `undefined` in `@12-apps/auth` for a release.
|
|
55
|
+
*/
|
|
56
|
+
export const notificationEmailPreviewsWebManifest = {
|
|
57
|
+
name: '@12-apps/notifications-email-previews',
|
|
58
|
+
surface: { create: createEmailPreviewScreen },
|
|
59
|
+
} as const satisfies AnyWebManifest;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type NotificationsSubscribe,
|
|
11
11
|
} from './hooks';
|
|
12
12
|
import { createInboxStore, type InboxStore } from './inbox-state';
|
|
13
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
13
14
|
import { lazyNotificationsPanel } from './panel-lazy';
|
|
14
15
|
import type { NotificationsPanelProps } from './panel';
|
|
15
16
|
import { lazyPreferencesPage } from './page-lazy';
|
|
@@ -55,6 +56,15 @@ export interface NotificationsWebConfig {
|
|
|
55
56
|
useSignal?: NotificationsSignalHook;
|
|
56
57
|
/** The browser push enable step's host seams (SW path, platform hint). */
|
|
57
58
|
webPush?: WebPushSetupConfig;
|
|
59
|
+
/**
|
|
60
|
+
* LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.
|
|
61
|
+
*
|
|
62
|
+
* Opt-in, and absent means absent: a host that passes nothing gets the panel
|
|
63
|
+
* it had, with no section, no heading and no reserved space. See
|
|
64
|
+
* `./live-config` for the two things a host has to supply (where they come
|
|
65
|
+
* from, and what the section says) and `../live` for what one IS.
|
|
66
|
+
*/
|
|
67
|
+
liveActivities?: LiveActivitiesConfig;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
export interface WebNotifications {
|
|
@@ -110,7 +120,11 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
|
|
|
110
120
|
const Bell: ComponentType<BellButtonProps> = (props) => (
|
|
111
121
|
<BellButton {...props} store={store} messages={messages} {...subscribeOption} />
|
|
112
122
|
);
|
|
113
|
-
const Panel = lazyNotificationsPanel({
|
|
123
|
+
const Panel = lazyNotificationsPanel({
|
|
124
|
+
store,
|
|
125
|
+
messages,
|
|
126
|
+
...(config.liveActivities ? { live: config.liveActivities } : {}),
|
|
127
|
+
});
|
|
114
128
|
|
|
115
129
|
function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
|
|
116
130
|
return useUnreadCount(store, { ...options, ...subscribeOption });
|
package/src/react/index.ts
CHANGED
|
@@ -44,6 +44,35 @@ export {
|
|
|
44
44
|
type NotificationsSubscribe,
|
|
45
45
|
} from './hooks';
|
|
46
46
|
|
|
47
|
+
// `LiveActivityCard` is deliberately NOT exported. It takes the clock as a
|
|
48
|
+
// prop, and the minute tick that produces one lives in `LiveSection` — so an
|
|
49
|
+
// external composer would either reimplement the tick or pass `Date.now()` once
|
|
50
|
+
// and get the frozen timestamp `relative-time.ts` was changed to prevent.
|
|
51
|
+
// `LiveSection` is the composable unit and carries its own clock.
|
|
52
|
+
export {
|
|
53
|
+
type LiveActivitiesConfig,
|
|
54
|
+
type LiveActivitiesHook,
|
|
55
|
+
type LiveActivityMessages,
|
|
56
|
+
} from './live-config';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The contract itself, re-exported from the entry a host WIRES through.
|
|
60
|
+
*
|
|
61
|
+
* It lives in the root, because both halves need it and neither owns it — but a
|
|
62
|
+
* host writing `useActivities` imports the seam from `./react` and then has to
|
|
63
|
+
* reach into a second entry for the one type that seam returns. Measured on the
|
|
64
|
+
* first adopter: a `LiveActivity` imported from here failed to compile with
|
|
65
|
+
* "declares 'LiveActivity' locally, but it is not exported", which is a
|
|
66
|
+
* confusing way to say "you wanted the other subpath".
|
|
67
|
+
*/
|
|
68
|
+
export {
|
|
69
|
+
liveActivityLane,
|
|
70
|
+
type LiveActivity,
|
|
71
|
+
type LiveActivityLane,
|
|
72
|
+
type LiveActivityStep,
|
|
73
|
+
} from '../live';
|
|
74
|
+
export { LiveSection, type LiveSectionProps } from './live-section';
|
|
75
|
+
|
|
47
76
|
export { relativeTime } from './relative-time';
|
|
48
77
|
|
|
49
78
|
export {
|
|
@@ -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
|
+
}
|