@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
package/ADOPTING.md
CHANGED
|
@@ -448,13 +448,72 @@ contract `EmailPreviewMessage` states. The one mistake a preview surface must be
|
|
|
448
448
|
incapable of — putting a sample in somebody's inbox — is not reachable from that
|
|
449
449
|
code at all, rather than merely not done.
|
|
450
450
|
|
|
451
|
+
## Live activities (optional)
|
|
452
|
+
|
|
453
|
+
The panel's second kind of entry: ONGOING STATE, pinned above the inbox list.
|
|
454
|
+
Off unless you configure it, and off means *nothing rendered* — no section, no
|
|
455
|
+
heading, no reserved space. `README.md` carries the argument and the full shape;
|
|
456
|
+
this is the adoption contract.
|
|
457
|
+
|
|
458
|
+
**You supply two things and this package owns the rest.**
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
createWebNotifications({
|
|
462
|
+
…,
|
|
463
|
+
liveActivities: {
|
|
464
|
+
useActivities: ({ active }) => useMyLiveThings({ enabled: active }),
|
|
465
|
+
messages: { sectionTitle, openActivity, updated },
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
1. **Where they come from.** A HOOK, not a fetcher and not a factory-time
|
|
471
|
+
subscribe — the answer almost always lives in React context (your tenant,
|
|
472
|
+
your session, your query client), which is the same reason
|
|
473
|
+
`NotificationsSignalHook` exists one seam over. `active` is `false` while the
|
|
474
|
+
panel is shut; pass it to your query's `enabled`. Ignoring it is correct and
|
|
475
|
+
merely costs money — and an unopened inbox is free either way, because the
|
|
476
|
+
panel is lazy and the drawer unmounts its content on close.
|
|
477
|
+
2. **Three sentences**, in their own pack rather than on
|
|
478
|
+
`NotificationMessages` — live activities are opt-in, and requiring copy for a
|
|
479
|
+
section you never render is the tax that gets a required-config change
|
|
480
|
+
reverted instead of adopted.
|
|
481
|
+
|
|
482
|
+
**Two rules that are ours, not yours:**
|
|
483
|
+
|
|
484
|
+
- A live activity NEVER touches `unread`. It is not news, and a number the bell
|
|
485
|
+
cannot clear is worse than no number.
|
|
486
|
+
- It leaves when your hook stops returning it. There is no dismiss, no read and
|
|
487
|
+
no delete — the subject finishing is the only exit, which is what stops the
|
|
488
|
+
section becoming a second inbox.
|
|
489
|
+
|
|
490
|
+
**On a phone**, put the activity's id on the notifications you already send
|
|
491
|
+
about the same subject:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import { LIVE_SUBJECT_KEY } from '@12-apps/notifications'; // the root entry
|
|
495
|
+
|
|
496
|
+
data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
(`LiveActivity` itself comes from either entry — the root or `./react` — so the
|
|
500
|
+
hook and the type it returns are one import line.)
|
|
501
|
+
|
|
502
|
+
`formatWebPush` turns it into `tag` on the push payload; your service worker
|
|
503
|
+
passes `tag` to `showNotification` and the tray keeps ONE entry per subject
|
|
504
|
+
instead of one per stage. The worker is still yours (rule 12) — a worker that
|
|
505
|
+
ignores `tag` behaves exactly as it does today.
|
|
506
|
+
|
|
451
507
|
## What does NOT come with it
|
|
452
508
|
|
|
453
509
|
- **The events.** Generators are host code (see rule 6).
|
|
454
510
|
- **A queue.** `scheduleDispatch` is a seam, not an implementation.
|
|
455
511
|
- **A plan model.** `channelPolicy` answers; it does not decide.
|
|
456
512
|
- **An authorization engine.** `audience` answers; it does not decide.
|
|
457
|
-
- **A service worker.** The file is the host's (rule 12)
|
|
513
|
+
- **A service worker.** The file is the host's (rule 12) — including the four
|
|
514
|
+
lines that read `tag` off a live push.
|
|
515
|
+
- **Anything to BE live about.** `useActivities` is a seam; this package has no
|
|
516
|
+
idea what is happening in your product, which is the point.
|
|
458
517
|
- **The mail INVENTORY.** The preview console renders what you declare as
|
|
459
518
|
sources; it cannot discover what your product sends.
|
|
460
519
|
- **A brand, a palette or a sentence.** The layout requires the first, defaults
|
package/README.md
CHANGED
|
@@ -186,6 +186,79 @@ holds no driver, no transport and no address.
|
|
|
186
186
|
|
|
187
187
|
See **[ADOPTING.md](./ADOPTING.md)** for the mount, the gate and the sources.
|
|
188
188
|
|
|
189
|
+
## Live activities — the centre's second kind of entry
|
|
190
|
+
|
|
191
|
+
An inbox notification is an EVENT: it happened, it is stamped, it is read or
|
|
192
|
+
unread, and tomorrow it still says the same thing. A **live activity** is
|
|
193
|
+
ONGOING STATE — pinned above the list, no read/unread, nothing to delete, it
|
|
194
|
+
updates itself, and it is gone the moment the thing it tracks finishes.
|
|
195
|
+
|
|
196
|
+
The distinction is not cosmetic. "Your order is on its way", read an hour later,
|
|
197
|
+
is a claim about the past presented as news; the more reliable the inbox is, the
|
|
198
|
+
more of those a person accumulates, and somewhere in the pile is the question
|
|
199
|
+
they actually have — *where is it now*.
|
|
200
|
+
|
|
201
|
+
Opt-in, and absent means absent: a host that passes nothing renders the panel it
|
|
202
|
+
had, with no section, no heading and no reserved space.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
createWebNotifications({
|
|
206
|
+
apiBase: '/api/account',
|
|
207
|
+
messages,
|
|
208
|
+
liveActivities: {
|
|
209
|
+
// A HOOK, so it may read context — the tenant, the session, a query client.
|
|
210
|
+
useActivities: ({ active }) => useMyLiveThings({ enabled: active }),
|
|
211
|
+
messages: { sectionTitle, openActivity, updated },
|
|
212
|
+
renderIcon: (activity) => ICONS[activity.kind],
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`active` is whether anyone is looking — `false` while the panel is shut. Pass it
|
|
218
|
+
to your query's `enabled`. It is a hint about NEED, never about correctness, and
|
|
219
|
+
it is not what makes an unopened inbox free: the panel is fetched lazily and the
|
|
220
|
+
drawer unmounts its content on close, so a host that ignores `active` still
|
|
221
|
+
issues nothing until somebody opens the bell.
|
|
222
|
+
|
|
223
|
+
One activity is `{ id, kind, title, body, link, steps, activeStepId, updatedAt }`
|
|
224
|
+
— `LiveActivity`, importable from either entry (`@12-apps/notifications` or
|
|
225
|
+
`@12-apps/notifications/react`, so a host wiring the surface needs one import
|
|
226
|
+
line, not two). It is defined in `src/live.ts`. `steps` + `activeStepId` draw a lane, because "how far along is
|
|
227
|
+
this" is the shape almost every ongoing subject has; both are optional in effect
|
|
228
|
+
— an activity with no lane is a heading, a sentence and a timestamp that keeps
|
|
229
|
+
moving. An `activeStepId` naming no step draws NO lane rather than a lane with
|
|
230
|
+
nothing lit, which would read as a process that has stopped.
|
|
231
|
+
|
|
232
|
+
### On a phone: one tray entry, one buzz
|
|
233
|
+
|
|
234
|
+
The other half is the OS notification, and it is one field. A generator whose
|
|
235
|
+
event is about something also tracked live puts the activity's id in `data`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { LIVE_SUBJECT_KEY } from '@12-apps/notifications'; // the root entry
|
|
239
|
+
|
|
240
|
+
data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`formatWebPush` turns that into a `tag` on the push payload, and a tag makes the
|
|
244
|
+
next push about the same subject **replace** the one already in the tray —
|
|
245
|
+
silently — instead of stacking under it. Four stages then cost one entry and one
|
|
246
|
+
buzz, and the entry that remains is the current one. Without it a phone
|
|
247
|
+
accumulates one alert per stage, each still asserting a stage the subject has
|
|
248
|
+
since left.
|
|
249
|
+
|
|
250
|
+
`tag` is `null` for an ordinary event rather than absent, so a service worker
|
|
251
|
+
reads one payload shape. A worker that ignores it keeps today's behaviour
|
|
252
|
+
exactly — which is what makes the field safe to ship ahead of the workers:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
self.registration.showNotification(payload.title, {
|
|
256
|
+
body: payload.body,
|
|
257
|
+
data: { link: payload.link },
|
|
258
|
+
...(payload.tag ? { tag: payload.tag, renotify: false } : {}),
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
189
262
|
## The models
|
|
190
263
|
|
|
191
264
|
`prisma/notifications.prisma` — `Notification`, `NotificationDelivery`,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Core types of the channel-agnostic notification system (12-15).\n *\n * Three decoupled layers, each open for extension without touching the others:\n * - GENERATORS map a typed domain event to agnostic content (title/body/…).\n * - The CHANNEL ROUTER always writes the notification-centre inbox record,\n * then fans out one delivery per enabled channel.\n * - TRANSPORTS format the agnostic content for one channel and send it.\n *\n * Nothing here knows about a concrete channel's wire format — that lives\n * entirely inside each transport adapter — and nothing here knows about a\n * concrete DOMAIN either: the event `type` set, the preference categories and\n * the channel list are all host config (see {@link NotificationTaxonomy}).\n */\n\n/** Transport channels a notification can fan out to (DB CHECK mirrors this). */\nexport const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;\nexport type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];\n\n/**\n * The preference categories are the HOST's, and required.\n *\n * There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',\n * 'system']` here — one product's set — and `taxonomyOf` fell back to it\n * whenever a host passed none. The docstring argued the case itself: \"it is\n * product vocabulary, not machinery\", and then shipped the vocabulary anyway as\n * the default, which is the only part a forgetful host would ever see.\n *\n * The consequence was quiet rather than loud: the settings screen renders four\n * rows a foreign host never chose, its own categories are absent, and every\n * preference a user sets is filed against a taxonomy nothing else in that\n * system uses. Nothing throws, because `category` is deliberately a free string\n * — the packaged migration puts **no CHECK** on it, precisely because a closed\n * set would be wrong for every host but the first. That freedom is what made\n * the default undetectable.\n *\n * `channel` and `status` are different and keep their CHECKs: those ARE this\n * library's own closed sets.\n */\nexport type NotificationCategory = string;\n\n/**\n * Per-channel delivery lifecycle (DB CHECK mirrors this).\n *\n * `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,\n * so two dispatchers can never both send the same delivery. A row left\n * `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once\n * it is older than the cutoff.\n *\n * `DEAD` is terminal: the attempt ceiling was reached (or the recipient no\n * longer exists), and no sweep will pick the row up again. Without it a\n * permanently invalid destination is a billed provider call on every sweep,\n * forever, and the sweep's working set only grows.\n */\nexport type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';\n\n/**\n * Channel-agnostic content a generator produces. This is what the inbox stores\n * verbatim and what every transport's formatter receives — no channel may leak\n * its wire format into it.\n */\nexport interface NotificationContent {\n title: string;\n body: string;\n /** In-app deep link (a same-origin path such as `/orders/123`). */\n link?: string;\n /**\n * Structured extras for consumers that want more than text.\n *\n * ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties\n * this notification to a live activity, and the WEB_PUSH transport turns it\n * into the tray `tag` that collapses every push about one subject onto a\n * single entry. A host already using that name for something else acquires\n * that behaviour without asking for it — so it is named here, on the field a\n * generator actually writes, and not only where the feature is documented.\n */\n data?: Record<string, unknown>;\n}\n\n/** Who receives a notification. `clientId` scopes it to a tenant when set. */\nexport interface NotificationRecipient {\n userId: string;\n clientId?: string;\n}\n\n/**\n * A typed domain event handed to `notify`. `type` selects the registered\n * generator; `payload` is that generator's typed input. Callers never touch\n * channels, formatting, or preferences.\n */\nexport interface NotificationEvent<TPayload = unknown> {\n type: string;\n recipient: NotificationRecipient;\n payload: TPayload;\n}\n\n/**\n * Maps one domain event type to agnostic content. Registered through the\n * server config (or `registerGenerator` for a late arrival); adding a\n * generator never touches existing generators, the router, or any transport\n * (open/closed).\n */\n/**\n * Who the content is being rendered FOR — the reader, at the moment the\n * generator is asked.\n *\n * A notification is stored as rendered TEXT: title and body are columns, so\n * the language is chosen once, when the row is written, and never again. That\n * makes this the only honest place to ask. A generator is registered at BOOT —\n * a host that resolved its words there would pin every future reader to\n * whichever language the process happened to start in, invisibly, because a\n * single-locale host cannot tell the difference.\n *\n * The tag is the RECIPIENT's, never the request's. The person who triggers a\n * notification is routinely not the person who reads it: an invite is sent\n * because an administrator acted and is read by the invitee. Reading\n * `Accept-Language` here would be a bug that only ever surfaces as somebody\n * being told things in a language they do not speak.\n *\n * Absent means \"nobody said\" — a host with one audience, or one that stores no\n * per-user language, populates nothing and every generator answers with its\n * own default exactly as it did before this existed.\n */\nexport interface NotificationGenerateContext {\n readonly locale?: string | null;\n}\n\nexport interface NotificationGenerator<TPayload = unknown> {\n /** The event key, dot-namespaced (\"order.paid\"). One generator per type. */\n type: string;\n /** The preference category the router gates this type's fan-out on. */\n category: NotificationCategory;\n /**\n * Render this event's content for ONE recipient.\n *\n * `context` is OPTIONAL, and that is what keeps every generator written\n * before it working: a one-parameter `generate` is assignable to this\n * signature unchanged. A host that passes nothing is stating a fact — it has\n * no language for this reader — rather than asserting a default.\n */\n generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;\n}\n\n/**\n * The recipient as a transport sees them: resolved destinations only. Built by\n * the router from the host's contact directory + the push subscriptions this\n * package owns; transports use it to answer\n * {@link NotificationTransport.supports}.\n */\nexport interface TransportRecipient {\n userId: string;\n email: string | null;\n /**\n * The recipient's own language, when the host's contact directory states\n * one. Absent means \"nobody said\" — see {@link NotificationGenerateContext}.\n */\n locale?: string | null;\n /** Phone as the host stores it (transports normalize per provider rules). */\n phone: string | null;\n /** How many active browser push subscriptions the user holds. */\n pushSubscriptionCount: number;\n}\n\n/**\n * One pluggable channel adapter: a FORMATTER (agnostic content → channel\n * message) plus a SENDER. Adding a channel = registering one of these; the\n * router dispatches through the registry and needs no change.\n *\n * `send` resolves on success and THROWS on failure — the router records the\n * error on the delivery row and isolates it from other channels. Sends must be\n * retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.\n */\nexport interface NotificationTransport<TMessage = unknown> {\n channel: NotificationChannel;\n /**\n * Whether this recipient is addressable on this channel right now — the\n * destination exists (e-mail / phone / push subscription) AND the provider\n * is configured. `false` simply skips the channel (no delivery row).\n */\n supports(recipient: TransportRecipient): boolean;\n /** Transform the agnostic content into this channel's message shape. */\n format(content: NotificationContent): TMessage;\n /** Deliver the formatted message to the recipient. Throws on failure. */\n send(message: TMessage, recipient: TransportRecipient): Promise<void>;\n}\n\n/**\n * The host's product vocabulary. Everything below the surface (routing,\n * delivery rows, retries, the wire) is identical for every host; WHICH\n * categories exist and how they are labelled is not.\n */\nexport interface NotificationTaxonomy {\n /** The preference categories, in the order the settings screen lists them. */\n categories: readonly NotificationCategory[];\n}\n\n/**\n * The taxonomy in force. `categories` is REQUIRED — see above.\n *\n * The empty check was already here and stays: an empty list and a missing one\n * are the same mistake, and both now fail at assembly rather than rendering an\n * empty settings screen or somebody else's four rows.\n */\nexport function taxonomyOf(config: {\n categories: readonly NotificationCategory[];\n}): NotificationTaxonomy {\n const categories = config.categories;\n if (!categories || categories.length === 0) {\n throw new Error(\n '@12-apps/notifications: `categories` is required and must not be empty — ' +\n 'the preference categories are the host\\'s product vocabulary.',\n );\n }\n return { categories: [...categories] };\n}\n\n/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */\nexport interface NotificationLogger {\n info(message: string, ...meta: unknown[]): void;\n error(message: string, ...meta: unknown[]): void;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AA2LrE,SAAS,WAAW,QAEF;AACvB,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,cAAc,WAAW,WAAW,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,EAAE,YAAY,CAAC,GAAG,UAAU,EAAE;AACvC;AAXgB;","names":[]}
|
|
@@ -105,8 +105,77 @@ __name(httpNotificationsTransport, "httpNotificationsTransport");
|
|
|
105
105
|
import { useState as useState2 } from "react";
|
|
106
106
|
|
|
107
107
|
// src/react/bell-button.tsx
|
|
108
|
+
import { useSyncExternalStore } from "react";
|
|
108
109
|
import { Badge } from "@12-apps/ui/data-display/Badge";
|
|
109
110
|
import { Box } from "@12-apps/ui/mui/Box";
|
|
111
|
+
|
|
112
|
+
// src/react/live-seen.ts
|
|
113
|
+
var STORAGE_KEY = "12a.notifications.live-seen";
|
|
114
|
+
var EMPTY = {};
|
|
115
|
+
function instant(iso) {
|
|
116
|
+
if (iso === void 0) return null;
|
|
117
|
+
const ms = Date.parse(iso);
|
|
118
|
+
return Number.isNaN(ms) ? null : ms;
|
|
119
|
+
}
|
|
120
|
+
__name(instant, "instant");
|
|
121
|
+
function readStored() {
|
|
122
|
+
try {
|
|
123
|
+
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
|
|
124
|
+
if (!raw) return EMPTY;
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return EMPTY;
|
|
127
|
+
const clean = {};
|
|
128
|
+
for (const [id, value] of Object.entries(parsed)) {
|
|
129
|
+
if (typeof value === "string") clean[id] = value;
|
|
130
|
+
}
|
|
131
|
+
return clean;
|
|
132
|
+
} catch {
|
|
133
|
+
return EMPTY;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
__name(readStored, "readStored");
|
|
137
|
+
function writeStored(value) {
|
|
138
|
+
try {
|
|
139
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value));
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
__name(writeStored, "writeStored");
|
|
144
|
+
function createLiveSeenStore() {
|
|
145
|
+
let current = readStored();
|
|
146
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
147
|
+
return {
|
|
148
|
+
read: /* @__PURE__ */ __name(() => current, "read"),
|
|
149
|
+
mark: /* @__PURE__ */ __name((activities) => {
|
|
150
|
+
const next = {};
|
|
151
|
+
for (const activity of activities) next[activity.id] = activity.updatedAt;
|
|
152
|
+
const ids = Object.keys(next);
|
|
153
|
+
const same = ids.length === Object.keys(current).length && ids.every((id) => current[id] === next[id]);
|
|
154
|
+
if (same) return;
|
|
155
|
+
current = next;
|
|
156
|
+
writeStored(next);
|
|
157
|
+
for (const listener of listeners) listener();
|
|
158
|
+
}, "mark"),
|
|
159
|
+
subscribe: /* @__PURE__ */ __name((listener) => {
|
|
160
|
+
listeners.add(listener);
|
|
161
|
+
return () => {
|
|
162
|
+
listeners.delete(listener);
|
|
163
|
+
};
|
|
164
|
+
}, "subscribe")
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
__name(createLiveSeenStore, "createLiveSeenStore");
|
|
168
|
+
function hasUnseenActivity(activities, seen) {
|
|
169
|
+
return activities.some((activity) => {
|
|
170
|
+
const shown = instant(seen[activity.id]);
|
|
171
|
+
if (shown === null) return true;
|
|
172
|
+
const now = instant(activity.updatedAt);
|
|
173
|
+
return now === null || now > shown;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
__name(hasUnseenActivity, "hasUnseenActivity");
|
|
177
|
+
|
|
178
|
+
// src/react/bell-button.tsx
|
|
110
179
|
import { jsx } from "react/jsx-runtime";
|
|
111
180
|
var triggerSx = {
|
|
112
181
|
display: "inline-flex",
|
|
@@ -127,19 +196,12 @@ var triggerSx = {
|
|
|
127
196
|
borderRadius: "50%"
|
|
128
197
|
}
|
|
129
198
|
};
|
|
130
|
-
function
|
|
199
|
+
function BellTrigger({
|
|
131
200
|
onClick,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
messages
|
|
135
|
-
subscribe,
|
|
136
|
-
useSignal
|
|
201
|
+
count,
|
|
202
|
+
hasNew,
|
|
203
|
+
messages
|
|
137
204
|
}) {
|
|
138
|
-
const count = useUnreadCount(store, {
|
|
139
|
-
enabled,
|
|
140
|
-
...subscribe ? { subscribe } : {},
|
|
141
|
-
...useSignal ? { useSignal } : {}
|
|
142
|
-
});
|
|
143
205
|
return /* @__PURE__ */ jsx(
|
|
144
206
|
Box,
|
|
145
207
|
{
|
|
@@ -153,24 +215,70 @@ function BellButton({
|
|
|
153
215
|
Badge,
|
|
154
216
|
{
|
|
155
217
|
content: count > 0 ? count : void 0,
|
|
156
|
-
color: "primary",
|
|
218
|
+
color: hasNew ? "primary" : "neutral",
|
|
157
219
|
variant: "count",
|
|
158
220
|
max: 99,
|
|
159
221
|
"data-testid": "notifications-badge",
|
|
222
|
+
"data-tone": hasNew ? "new" : "seen",
|
|
160
223
|
children: /* @__PURE__ */ jsx(BellIcon, { size: 28 })
|
|
161
224
|
}
|
|
162
225
|
)
|
|
163
226
|
}
|
|
164
227
|
);
|
|
165
228
|
}
|
|
229
|
+
__name(BellTrigger, "BellTrigger");
|
|
230
|
+
function BellButton({
|
|
231
|
+
onClick,
|
|
232
|
+
enabled = true,
|
|
233
|
+
store,
|
|
234
|
+
messages,
|
|
235
|
+
subscribe,
|
|
236
|
+
useSignal
|
|
237
|
+
}) {
|
|
238
|
+
const count = useUnreadCount(store, {
|
|
239
|
+
enabled,
|
|
240
|
+
...subscribe ? { subscribe } : {},
|
|
241
|
+
...useSignal ? { useSignal } : {}
|
|
242
|
+
});
|
|
243
|
+
return /* @__PURE__ */ jsx(BellTrigger, { onClick, count, hasNew: count > 0, messages });
|
|
244
|
+
}
|
|
166
245
|
__name(BellButton, "BellButton");
|
|
246
|
+
function LiveBellButton({
|
|
247
|
+
onClick,
|
|
248
|
+
enabled = true,
|
|
249
|
+
store,
|
|
250
|
+
messages,
|
|
251
|
+
subscribe,
|
|
252
|
+
useSignal,
|
|
253
|
+
live,
|
|
254
|
+
seen
|
|
255
|
+
}) {
|
|
256
|
+
const unread = useUnreadCount(store, {
|
|
257
|
+
enabled,
|
|
258
|
+
...subscribe ? { subscribe } : {},
|
|
259
|
+
...useSignal ? { useSignal } : {}
|
|
260
|
+
});
|
|
261
|
+
const activities = live.useActivities({ active: enabled });
|
|
262
|
+
const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
|
|
263
|
+
const liveCount = enabled ? activities.length : 0;
|
|
264
|
+
return /* @__PURE__ */ jsx(
|
|
265
|
+
BellTrigger,
|
|
266
|
+
{
|
|
267
|
+
onClick,
|
|
268
|
+
count: unread + liveCount,
|
|
269
|
+
hasNew: unread > 0 || enabled && hasUnseenActivity(activities, seenIso),
|
|
270
|
+
messages
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
__name(LiveBellButton, "LiveBellButton");
|
|
167
275
|
|
|
168
276
|
// src/react/panel-lazy.tsx
|
|
169
277
|
import { Suspense, lazy, useEffect, useState } from "react";
|
|
170
278
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
171
279
|
function lazyNotificationsPanel(parts) {
|
|
172
280
|
const Bound = lazy(async () => {
|
|
173
|
-
const { NotificationsPanel } = await import("./panel-
|
|
281
|
+
const { NotificationsPanel } = await import("./panel-OPB3DBLJ.js");
|
|
174
282
|
return {
|
|
175
283
|
default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx2(NotificationsPanel, { ...props, ...parts }), "default")
|
|
176
284
|
};
|
|
@@ -191,7 +299,7 @@ import { Suspense as Suspense2, lazy as lazy2 } from "react";
|
|
|
191
299
|
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
192
300
|
function lazyPreferencesPage(parts) {
|
|
193
301
|
const Bound = lazy2(async () => {
|
|
194
|
-
const { PreferencesScreen } = await import("./preferences-screen-
|
|
302
|
+
const { PreferencesScreen } = await import("./preferences-screen-SXUIGECY.js");
|
|
195
303
|
return {
|
|
196
304
|
default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx3(PreferencesScreen, { ...props, ...parts }), "default")
|
|
197
305
|
};
|
|
@@ -217,8 +325,24 @@ function createWebNotifications(config) {
|
|
|
217
325
|
...subscribe ? { subscribe } : {},
|
|
218
326
|
...config.useSignal ? { useSignal: config.useSignal } : {}
|
|
219
327
|
};
|
|
220
|
-
const
|
|
221
|
-
const
|
|
328
|
+
const liveSeen = createLiveSeenStore();
|
|
329
|
+
const live = config.liveActivities;
|
|
330
|
+
const Bell = live ? (props) => /* @__PURE__ */ jsx4(
|
|
331
|
+
LiveBellButton,
|
|
332
|
+
{
|
|
333
|
+
...props,
|
|
334
|
+
store,
|
|
335
|
+
messages,
|
|
336
|
+
live,
|
|
337
|
+
seen: liveSeen,
|
|
338
|
+
...subscribeOption
|
|
339
|
+
}
|
|
340
|
+
) : (props) => /* @__PURE__ */ jsx4(BellButton, { ...props, store, messages, ...subscribeOption });
|
|
341
|
+
const Panel = lazyNotificationsPanel({
|
|
342
|
+
store,
|
|
343
|
+
messages,
|
|
344
|
+
...live ? { live, liveSeen } : {}
|
|
345
|
+
});
|
|
222
346
|
function useBoundUnreadCount(options = {}) {
|
|
223
347
|
return useUnreadCount(store, { ...options, ...subscribeOption });
|
|
224
348
|
}
|
|
@@ -260,4 +384,4 @@ export {
|
|
|
260
384
|
httpNotificationsTransport,
|
|
261
385
|
createWebNotifications
|
|
262
386
|
};
|
|
263
|
-
//# sourceMappingURL=chunk-
|
|
387
|
+
//# sourceMappingURL=chunk-I5QUMTCN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/api.ts","../src/react/transport.ts","../src/react/create-web-notifications.tsx","../src/react/bell-button.tsx","../src/react/live-seen.ts","../src/react/panel-lazy.tsx","../src/react/page-lazy.tsx"],"sourcesContent":["import type { ChannelRow } from '../preferences-core';\nimport type { NotificationChannel } from '../types';\nimport type { ListNotificationsResult } from '../wire';\n\nimport type { NotificationsResult, NotificationsTransport } from './transport';\n\n/**\n * The wire client, bound to one mount (12-15).\n *\n * Every path this package's screens can call, in one place — which is what\n * makes the api half's route table and the web half's URLs one contract instead\n * of two lists that drift.\n */\n\n/** `GET <mount>/notification-preferences` and the PUT's answer. */\nexport interface PreferencesPayload {\n preferences: Record<string, ChannelRow>;\n availability: Record<NotificationChannel, boolean>;\n /** The host's taxonomy, so the screen renders it without being told twice. */\n categories: string[];\n}\n\n/** `GET <mount>/push-subscriptions`. */\nexport interface PushRegistrationPayload {\n /** null = web push is not configured on this deployment. */\n vapidPublicKey: string | null;\n count: number;\n /**\n * Whether the endpoint asked about is still registered to the caller. Present\n * only when one was passed — see {@link NotificationsApiClient.getPushRegistration}.\n */\n registered?: boolean;\n}\n\nexport interface NotificationsApiClient {\n listNotifications(input: {\n cursor?: string | null;\n limit?: number;\n filter?: 'all' | 'unread';\n }): Promise<ListNotificationsResult>;\n unreadCount(): Promise<number>;\n markRead(ids: readonly string[]): Promise<NotificationsResult<{ updated: number }>>;\n markAllRead(): Promise<NotificationsResult<{ updated: number }>>;\n remove(ids: readonly string[]): Promise<NotificationsResult<{ deleted: number }>>;\n getPreferences(): Promise<PreferencesPayload>;\n savePreference(\n category: string,\n channel: NotificationChannel,\n enabled: boolean,\n ): Promise<NotificationsResult<PreferencesPayload>>;\n /**\n * The deployment's VAPID key and the caller's device count — and, when an\n * `endpoint` is passed, whether the SERVER still has that exact subscription\n * under the caller's id. The browser holding a subscription object is not\n * evidence of that: a re-own or a 404/410 prune drops the row and leaves the\n * browser's object in place.\n */\n getPushRegistration(input?: { endpoint?: string }): Promise<PushRegistrationPayload>;\n savePushSubscription(input: {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n }): Promise<NotificationsResult<{ count: number }>>;\n removePushSubscription(endpoint: string): Promise<NotificationsResult<{ count: number }>>;\n}\n\nexport function createNotificationsApiClient(\n apiBase: string,\n transport: NotificationsTransport,\n): NotificationsApiClient {\n const base = apiBase.replace(/\\/$/, '');\n const url = (path: string): string => `${base}${path}`;\n\n return {\n listNotifications({ cursor, limit, filter }) {\n const params = new URLSearchParams();\n if (limit !== undefined) params.set('limit', String(limit));\n if (cursor) params.set('cursor', cursor);\n if (filter) params.set('filter', filter);\n const query = params.toString();\n return transport.get<ListNotificationsResult>(\n url(`/notifications${query ? `?${query}` : ''}`),\n );\n },\n async unreadCount() {\n const { count } = await transport.get<{ count: number }>(\n url('/notifications/unread-count'),\n );\n return count;\n },\n markRead: (ids) =>\n transport.send(url('/notifications/mark-read'), 'POST', { ids: [...ids] }),\n markAllRead: () => transport.send(url('/notifications/mark-read'), 'POST', { all: true }),\n remove: (ids) => transport.send(url('/notifications/delete'), 'POST', { ids: [...ids] }),\n getPreferences: () => transport.get<PreferencesPayload>(url('/notification-preferences')),\n savePreference: (category, channel, enabled) =>\n transport.send(url('/notification-preferences'), 'PUT', {\n [category]: { [channel]: enabled },\n }),\n getPushRegistration: ({ endpoint } = {}) =>\n transport.get<PushRegistrationPayload>(\n url(\n endpoint\n ? `/push-subscriptions?endpoint=${encodeURIComponent(endpoint)}`\n : '/push-subscriptions',\n ),\n ),\n savePushSubscription: (input) => transport.send(url('/push-subscriptions'), 'POST', input),\n removePushSubscription: (endpoint) =>\n transport.send(url('/push-subscriptions'), 'DELETE', { endpoint }),\n };\n}\n","/**\n * How the notification screens reach their data (12-15) — the report-builder\n * transport doctrine: this is the ONLY way the surface performs I/O, so a\n * caller supplying one has substituted the entire backend without stubbing a\n * global. The default is same-origin `fetch` riding the browser's cookies.\n */\n\n/** A write outcome the screens branch on — never a thrown mutation. */\nexport type NotificationsResult<T> = { ok: true; data: T } | { ok: false; error: string };\n\n/** A failed read, carrying the status the screens branch on (401 = signed out). */\nexport class NotificationsHttpError extends Error {\n readonly status: number;\n constructor(status: number, message: string) {\n super(message);\n this.name = 'NotificationsHttpError';\n this.status = status;\n Object.setPrototypeOf(this, NotificationsHttpError.prototype);\n }\n}\n\nexport interface NotificationsTransport {\n /** A read. Returns the payload INSIDE the `{ data }` envelope. */\n get<T>(path: string): Promise<T>;\n /** A write. Returns a {@link NotificationsResult} rather than rejecting. */\n send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>>;\n}\n\n/**\n * @param fallbackError What a failed write says when the server sent no\n * sentence of its own — REQUIRED, the host's words. `createWebNotifications`\n * already passes its (equally required) `messages.operationFailed`; only a\n * host constructing the transport directly writes it here. The old default\n * was one application's Portuguese, and the only string in this package the\n * required-messages port did not cover.\n */\nexport function httpNotificationsTransport(fallbackError: string): NotificationsTransport {\n return {\n async get<T>(path: string): Promise<T> {\n const response = await fetch(path, {\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n const payload = (await response.json().catch(() => null)) as\n | { data?: T; error?: string }\n | null;\n if (!response.ok) {\n throw new NotificationsHttpError(\n response.status,\n payload?.error ?? `HTTP ${response.status} for ${path}`,\n );\n }\n return (payload?.data ?? payload) as T;\n },\n\n async send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>> {\n try {\n const response = await fetch(path, {\n method,\n credentials: 'same-origin',\n headers: {\n Accept: 'application/json',\n ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (response.status === 204) return { ok: true, data: undefined as T };\n const payload = (await response.json().catch(() => null)) as\n | { data?: T; error?: string }\n | null;\n if (!response.ok) return { ok: false, error: payload?.error ?? fallbackError };\n return { ok: true, data: (payload?.data ?? payload) as T };\n } catch {\n return { ok: false, error: fallbackError };\n }\n },\n };\n}\n","import { useState, type ComponentType, type JSX } from 'react';\n\nimport { messagesOf, type NotificationMessages } from '../messages';\n\nimport { createNotificationsApiClient, type NotificationsApiClient } from './api';\nimport { BellButton, LiveBellButton, type BellButtonProps } from './bell-button';\nimport {\n useUnreadCount,\n type NotificationsSignalHook,\n type NotificationsSubscribe,\n} from './hooks';\nimport { createInboxStore, type InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport { createLiveSeenStore } from './live-seen';\nimport { lazyNotificationsPanel } from './panel-lazy';\nimport type { NotificationsPanelProps } from './panel';\nimport { lazyPreferencesPage } from './page-lazy';\nimport type { PreferencesScreenProps } from './preferences-screen';\nimport { httpNotificationsTransport, type NotificationsTransport } from './transport';\nimport type { WebPushSetupConfig } from './web-push-setup';\n\n/**\n * The one thing this package exposes to a FRONTEND host (12-15).\n *\n * Everything the notification centre IS — the bell with its live badge, the\n * slide-over inbox with its optimistic mark-read / delete / mark-all and its\n * cursor pager, the preferences matrix with its availability hints and the\n * per-browser push enable step, and every wire call between them — lives inside\n * this package. The host names where the API is mounted, and that is the whole\n * wiring.\n *\n * `page` is the standalone surface (the preferences screen), which is the one\n * thing a host routes to. The bell and the panel are a PAIR a host drops into\n * its own chrome, and they share one store, so a read in the panel moves the\n * badge in the same tick.\n */\n\nexport interface NotificationsWebConfig {\n /** The account mount the routes live under, e.g. `/api/account`. */\n apiBase: string;\n /** How the surface reaches its data. Default: same-origin fetch. */\n transport?: NotificationsTransport;\n /** User-facing copy overrides (pt-BR product copy by default). */\n messages: NotificationMessages;\n /**\n * How the surface learns an inbox changed without asking — the host's message\n * bus. Without it the badge keeps its 60 s poll, which is the standing\n * contract rather than a fallback: a dropped event must cost latency, never\n * correctness.\n */\n subscribe?: NotificationsSubscribe;\n /**\n * The same wiring as a HOOK, for a host whose realtime connection lives in\n * React context — see `NotificationsSignalHook`. `subscribe` is read at\n * factory time, which such a host cannot reach.\n */\n useSignal?: NotificationsSignalHook;\n /** The browser push enable step's host seams (SW path, platform hint). */\n webPush?: WebPushSetupConfig;\n /**\n * LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.\n *\n * Opt-in, and absent means absent: a host that passes nothing gets the panel\n * it had, with no section, no heading and no reserved space. See\n * `./live-config` for the two things a host has to supply (where they come\n * from, and what the section says) and `../live` for what one IS.\n */\n liveActivities?: LiveActivitiesConfig;\n}\n\nexport interface WebNotifications {\n /**\n * The routed surface: the preferences screen.\n *\n * Loaded on demand — see `page-lazy.tsx`. A host that mounts only the bell and\n * the panel never downloads it, and a host that routes to it fetches it while\n * entering that route.\n */\n page: ComponentType<PreferencesScreenProps>;\n /** The bell, already bound to the shared store. */\n BellButton: ComponentType<BellButtonProps>;\n /**\n * The inbox slide-over, sharing that store.\n *\n * Loaded the first time it is opened — see `panel-lazy.tsx`. Until then a\n * host's chrome carries the bell and nothing else.\n */\n Panel: ComponentType<NotificationsPanelProps>;\n /**\n * Bell + panel as ONE element, for a host that just wants the feature in its\n * header and does not want to own the open/closed state.\n */\n BellWithPanel: ComponentType<{\n enabled?: boolean;\n onNavigate?: (link: string) => void;\n }>;\n /** The badge number, for a host with its own trigger chrome. */\n useUnreadCount: (options?: { enabled?: boolean }) => number;\n /** The shared client state, for host glue. */\n store: InboxStore;\n /** The bound wire client. */\n api: NotificationsApiClient;\n /** The copy in force, so a host's own chrome can reuse a sentence. */\n messages: NotificationMessages;\n}\n\nexport function createWebNotifications(config: NotificationsWebConfig): WebNotifications {\n const messages = messagesOf(config);\n const api = createNotificationsApiClient(\n config.apiBase,\n config.transport ?? httpNotificationsTransport(messages.operationFailed),\n );\n const store = createInboxStore(api);\n const webPush = config.webPush ?? {};\n const subscribe = config.subscribe;\n const subscribeOption = {\n ...(subscribe ? { subscribe } : {}),\n ...(config.useSignal ? { useSignal: config.useSignal } : {}),\n };\n\n // One store per factory, shared by the bell that READS it and the panel that\n // WRITES it — the same arrangement as the inbox store above, and for the same\n // reason: two independent copies would disagree about what the reader saw.\n const liveSeen = createLiveSeenStore();\n\n // Chosen ONCE, here, because `useActivities` is a hook and the choice must\n // not be made per render: a bell that read an optional config inside itself\n // would be calling a hook conditionally.\n const live = config.liveActivities;\n const Bell: ComponentType<BellButtonProps> = live\n ? (props) => (\n <LiveBellButton\n {...props}\n store={store}\n messages={messages}\n live={live}\n seen={liveSeen}\n {...subscribeOption}\n />\n )\n : (props) => (\n <BellButton {...props} store={store} messages={messages} {...subscribeOption} />\n );\n const Panel = lazyNotificationsPanel({\n store,\n messages,\n ...(live ? { live, liveSeen } : {}),\n });\n\n function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {\n return useUnreadCount(store, { ...options, ...subscribeOption });\n }\n\n function BellWithPanel({\n enabled = true,\n onNavigate,\n }: {\n enabled?: boolean;\n onNavigate?: (link: string) => void;\n }): JSX.Element {\n const [open, setOpen] = useState(false);\n return (\n <>\n <Bell enabled={enabled} onClick={() => setOpen(true)} />\n <Panel\n open={open}\n onClose={() => setOpen(false)}\n {...(onNavigate ? { onNavigate } : {})}\n />\n </>\n );\n }\n\n return {\n page: lazyPreferencesPage({ api, messages, webPush }),\n BellButton: Bell,\n Panel,\n BellWithPanel,\n useUnreadCount: useBoundUnreadCount,\n store,\n api,\n messages,\n };\n}\n","/**\n * Bare bell trigger with the live unread badge — for hosts that do not already\n * have a styled icon-button slot. A host with its own trigger chrome uses\n * `useUnreadCount` + `Panel` directly.\n */\nimport { useSyncExternalStore, type JSX } from 'react';\n\nimport { Badge } from '@12-apps/ui/data-display/Badge';\nimport { Box } from '@12-apps/ui/mui/Box';\n\nimport type { NotificationMessages } from '../messages';\n\nimport { BellIcon } from './bell-icon';\nimport { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';\nimport type { LiveActivitiesConfig } from './live-config';\nimport { hasUnseenActivity, type LiveSeenStore } from './live-seen';\nimport type { InboxStore } from './inbox-state';\n\nconst triggerSx = {\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n p: 0.5,\n border: 'none',\n background: 'none',\n cursor: 'pointer',\n color: 'text.primary',\n lineHeight: 0,\n '& *': { cursor: 'pointer' },\n '&:hover': { color: 'primary.main' },\n '&:focus-visible': {\n outline: '2px solid',\n outlineColor: 'primary.main',\n outlineOffset: '2px',\n borderRadius: '50%',\n },\n} as const;\n\nexport interface BellButtonProps {\n onClick: () => void;\n /** Signed-out hosts still mount the bell; `false` silences it. */\n enabled?: boolean;\n}\n\n/**\n * The trigger itself, given a count and whether any of it is NEW.\n *\n * Presentational, and shared by both bells below, so the two can never drift on\n * what the badge looks like — only on where the number comes from.\n *\n * ## The two tones\n *\n * `primary` says *something happened*; `neutral` says *something is present*. A\n * live activity is the reason that distinction has to exist: it stays on the\n * panel for as long as the thing is happening, so a bell that painted every\n * live entry as new would be permanently red for a pedido the reader already\n * looked at, and a bell that ignored them would say nothing at all while one\n * was running. Grey keeps the count honest without spending attention twice.\n */\nfunction BellTrigger({\n onClick,\n count,\n hasNew,\n messages,\n}: {\n onClick: () => void;\n count: number;\n hasNew: boolean;\n messages: NotificationMessages;\n}): JSX.Element {\n return (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={onClick}\n // `openBellWithUnread` rather than a new message, and not for want of\n // precision: `NotificationMessages` is REQUIRED of every host, so adding\n // a field is a breaking change to a package several apps already mount.\n // The sentence a host wrote for \"you have N\" is the sentence this wants.\n aria-label={count > 0 ? messages.openBellWithUnread(count) : messages.openBell}\n data-testid=\"notifications-bell\"\n sx={triggerSx}\n >\n <Badge\n content={count > 0 ? count : undefined}\n color={hasNew ? 'primary' : 'neutral'}\n variant=\"count\"\n max={99}\n data-testid=\"notifications-badge\"\n // The tone is carried by a colour, and a colour is not something a\n // test can read — nor, on its own, a signal every reader can. This is\n // what the tests assert on.\n data-tone={hasNew ? 'new' : 'seen'}\n >\n <BellIcon size={28} />\n </Badge>\n </Box>\n );\n}\n\nexport function BellButton({\n onClick,\n enabled = true,\n store,\n messages,\n subscribe,\n useSignal,\n}: BellButtonProps & {\n store: InboxStore;\n messages: NotificationMessages;\n subscribe?: NotificationsSubscribe;\n useSignal?: NotificationsSignalHook;\n}): JSX.Element {\n const count = useUnreadCount(store, {\n enabled,\n ...(subscribe ? { subscribe } : {}),\n ...(useSignal ? { useSignal } : {}),\n });\n // No live config on this host: unread IS the whole count, and an unread row\n // is by definition something the reader has not seen.\n return <BellTrigger onClick={onClick} count={count} hasNew={count > 0} messages={messages} />;\n}\n\n/**\n * The bell for a host that configured live activities.\n *\n * A SECOND component rather than a flag on the one above, because the host's\n * `useActivities` is a hook: reading an optional config inside one component\n * would mean calling it conditionally, which React reports as a crash in some\n * unrelated component rather than here. The factory knows statically which host\n * it is building for and picks one.\n *\n * ## What it costs the host, stated plainly\n *\n * The bell is mounted for as long as the app is, so unlike the panel's copy of\n * this hook there is no \"nobody is looking\" state to stand down in — `active`\n * is simply `enabled`. A host that answers by polling therefore polls for every\n * signed-in reader whether or not they ever open the centre. That is the price\n * of a badge that knows about live activities at all, and the reason to answer\n * this hook from a pushed cache rather than from an interval.\n */\nexport function LiveBellButton({\n onClick,\n enabled = true,\n store,\n messages,\n subscribe,\n useSignal,\n live,\n seen,\n}: BellButtonProps & {\n store: InboxStore;\n messages: NotificationMessages;\n subscribe?: NotificationsSubscribe;\n useSignal?: NotificationsSignalHook;\n live: LiveActivitiesConfig;\n seen: LiveSeenStore;\n}): JSX.Element {\n const unread = useUnreadCount(store, {\n enabled,\n ...(subscribe ? { subscribe } : {}),\n ...(useSignal ? { useSignal } : {}),\n });\n const activities = live.useActivities({ active: enabled });\n const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);\n const liveCount = enabled ? activities.length : 0;\n return (\n <BellTrigger\n onClick={onClick}\n // A live entry counts. It is a notification — it is the one the reader\n // most wants to know about — and the panel it opens lists it.\n count={unread + liveCount}\n hasNew={unread > 0 || (enabled && hasUnseenActivity(activities, seenIso))}\n messages={messages}\n />\n );\n}\n","/**\n * What the reader has already been shown, so the bell can say NEW rather than\n * merely PRESENT.\n *\n * A live activity is unlike an inbox row in the one way that matters here: it\n * stays on the panel for as long as the thing is happening, so its presence\n * cannot mean \"you have not seen this\". A pedido that has been `Preparo` for\n * ten minutes is still live and still worth counting, but nothing has happened\n * — and a badge that shouts for a subject the reader has already looked at is a\n * badge people stop reading.\n *\n * So presence and novelty are answered separately: the COUNT comes from how\n * many are live, and the TONE comes from this. The panel writes it — being on\n * screen is what seen means — and the bell reads it.\n *\n * ## Per subject, not one watermark\n *\n * A single \"newest instant already seen\" is smaller and was the first cut, and\n * it is wrong in a way that shows up in normal use: a pedido placed ten minutes\n * ago but only now reaching the client arrives with an `updatedAt` BEHIND the\n * watermark, and would be silently marked as already seen. The reader has never\n * laid eyes on it. Keyed by subject, an id that has not been recorded is new\n * whatever its clock says.\n *\n * Bounded by pruning rather than by expiry: every write keeps only the subjects\n * that are live at that moment, so the record can never outgrow the number of\n * things happening at once. A subject that finishes and later comes back is\n * news again, which is correct — it is a different occurrence.\n */\nimport type { LiveActivity } from '../live';\n\nconst STORAGE_KEY = '12a.notifications.live-seen';\n\n/** id -> the `updatedAt` that was on screen. */\ntype SeenMap = Readonly<Record<string, string>>;\n\nconst EMPTY: SeenMap = {};\n\n/** ms since epoch, or `null` for an absent or unparseable stamp. */\nfunction instant(iso: string | undefined): number | null {\n if (iso === undefined) return null;\n const ms = Date.parse(iso);\n return Number.isNaN(ms) ? null : ms;\n}\n\n/**\n * Read/write through `try`, every time.\n *\n * `localStorage` is not merely absent in SSR and in a worker — the ACCESSOR\n * itself throws in a browser set to block site data. A notification bell that\n * cannot render because storage is blocked is a worse failure than one that\n * forgets what was seen, and forgetting degrades in the safe direction: towards\n * saying something is happening.\n */\nfunction readStored(): SeenMap {\n try {\n const raw = globalThis.localStorage?.getItem(STORAGE_KEY);\n if (!raw) return EMPTY;\n const parsed: unknown = JSON.parse(raw);\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return EMPTY;\n // Anything can be in storage — another version of this package, or a person\n // with the devtools open. Keep only what has the shape this reads.\n const clean: Record<string, string> = {};\n for (const [id, value] of Object.entries(parsed)) {\n if (typeof value === 'string') clean[id] = value;\n }\n return clean;\n } catch {\n return EMPTY;\n }\n}\n\nfunction writeStored(value: SeenMap): void {\n try {\n globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value));\n } catch {\n // Blocked or full. The badge stays new a while longer; nothing else breaks.\n }\n}\n\nexport interface LiveSeenStore {\n /** What has been shown, keyed by subject id. */\n read: () => SeenMap;\n /** Record that exactly these are on screen now, forgetting subjects that are not. */\n mark: (activities: readonly LiveActivity[]) => void;\n subscribe: (listener: () => void) => () => void;\n}\n\nexport function createLiveSeenStore(): LiveSeenStore {\n // Mirrored in memory as well as in storage: `useSyncExternalStore` compares\n // snapshots by IDENTITY and calls `read` on every render, so parsing storage\n // there would hand it a fresh object each time and re-render for ever.\n let current = readStored();\n const listeners = new Set<() => void>();\n\n return {\n read: () => current,\n mark: (activities) => {\n const next: Record<string, string> = {};\n for (const activity of activities) next[activity.id] = activity.updatedAt;\n // Identity is the snapshot, so an unchanged map must not become a new\n // object — see `read` above.\n const ids = Object.keys(next);\n const same =\n ids.length === Object.keys(current).length &&\n ids.every((id) => current[id] === next[id]);\n if (same) return;\n current = next;\n writeStored(next);\n for (const listener of listeners) listener();\n },\n subscribe: (listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\n/**\n * Whether any of these has moved, or arrived, since the reader last looked.\n *\n * An id with nothing recorded is new — that is the case the per-subject record\n * exists for. An unparseable stamp is treated as new too: the alternative is\n * silently never alerting for a host whose clock format this does not read.\n */\nexport function hasUnseenActivity(\n activities: readonly LiveActivity[],\n seen: SeenMap,\n): boolean {\n return activities.some((activity) => {\n const shown = instant(seen[activity.id]);\n if (shown === null) return true;\n const now = instant(activity.updatedAt);\n return now === null || now > shown;\n });\n}\n","/**\n * The inbox slide-over, fetched the first time somebody opens it.\n *\n * The bell and the panel are a PAIR a host drops into its chrome, and that is\n * still true — but only the BELL is on screen when a page paints. The panel is\n * behind a tap, and a static import made every host pay for it up front: the\n * design-system `Drawer` and, through it, MUI's `SwipeableDrawer`, `Modal`,\n * `Slide` and the focus trap, plus the row, the empty state and the pager. On a\n * storefront that is a slide-over most visits never open, parsed before the\n * first screen can render.\n *\n * ## Why the gate is \"ever opened\" rather than `open`\n *\n * `lazy` fetches when a component first RENDERS, so a boundary that still\n * rendered the panel while closed would fetch immediately and buy nothing. This\n * renders `null` until the panel has been open once, which is what actually\n * defers the download to the tap.\n *\n * And once opened it STAYS mounted. Unmounting on close would throw away the\n * drawer's transition state, so the panel would vanish instead of sliding out,\n * and the entrance animation would re-run on every reopen — which someone\n * working through an inbox does repeatedly. The fetch happens once.\n *\n * The initial state reads `open` rather than starting at `false`, so a host that\n * mounts the panel already open renders it in the same commit instead of a frame\n * later.\n *\n * ## Why `null` for the fallback\n *\n * The only frame this can show anything is the one right after the tap, where a\n * spinner reads as a stall rather than as progress. The chunk is small and\n * same-origin.\n */\nimport { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } from 'react';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { LiveSeenStore } from './live-seen';\nimport type { NotificationsPanelProps } from './panel';\n\n/** What the factory binds into the panel, and the host never passes. */\ninterface PanelParts {\n store: InboxStore;\n messages: NotificationMessages;\n /** Absent unless the host turned live activities on — see `./live-config`. */\n live?: LiveActivitiesConfig;\n /** Travels with `live`: where the panel records what the reader has seen. */\n liveSeen?: LiveSeenStore;\n}\n\nexport function lazyNotificationsPanel(\n parts: PanelParts,\n): ComponentType<NotificationsPanelProps> {\n const Bound = lazy(async () => {\n const { NotificationsPanel } = await import('./panel');\n return {\n default: (props: NotificationsPanelProps): JSX.Element => (\n <NotificationsPanel {...props} {...parts} />\n ),\n };\n });\n\n return function NotificationsPanelSlot(props: NotificationsPanelProps): JSX.Element | null {\n const [everOpened, setEverOpened] = useState(props.open);\n\n useEffect(() => {\n if (props.open) setEverOpened(true);\n }, [props.open]);\n\n if (!everOpened) return null;\n\n return (\n <Suspense fallback={null}>\n <Bound {...props} />\n </Suspense>\n );\n };\n}\n","/**\n * The routed preferences screen, fetched when a host actually routes to it.\n *\n * `createWebNotifications` returns two different KINDS of thing, and its own\n * docstring says so: `page` is \"the standalone surface … the one thing a host\n * routes to\", while the bell and the panel \"are a PAIR a host drops into its own\n * chrome\". Chrome is on screen from the first paint; a routed surface is not.\n *\n * A static import made that distinction invisible to a bundler. Every host that\n * put the bell in its header also shipped the preferences matrix — its channel\n * toggles, the per-browser push enable step, and the design-system `Switch`\n * behind them — in the same chunk as the header. A storefront paid for a\n * settings screen a shopper never opens, before its first screen could render;\n * a host that renders its OWN preferences page paid for this one twice.\n *\n * So `page` now loads on demand. Nothing else moves: the bell, the panel and\n * `BellWithPanel` stay exactly as eager as the chrome they belong to, because\n * that is what they are.\n *\n * NO PREFETCH, deliberately, and this is the opposite call from a surface a\n * host opens from chrome it already has. A routed surface is reached by\n * NAVIGATION, and every host here already code-splits its routes — so the\n * fetch happens while the route is being entered, which is the moment a\n * prefetch would have been trying to anticipate. Warming it at factory time\n * would put the screen back on the boot path of every app, which is the whole\n * cost this removes.\n */\nimport { Suspense, lazy, type ComponentType, type JSX } from 'react';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { NotificationsApiClient } from './api';\nimport type { PreferencesScreenProps } from './preferences-screen';\nimport type { WebPushSetupConfig } from './web-push-setup';\n\n/** What the factory binds into the screen, and the host never passes. */\ninterface PreferencesPageParts {\n api: NotificationsApiClient;\n messages: NotificationMessages;\n webPush: WebPushSetupConfig;\n}\n\n/**\n * The routed screen, bound and loaded on first render.\n *\n * `lazy` memoises its factory, so the binding below happens once however many\n * times a host mounts the page — the same guarantee the direct call gave.\n *\n * The fallback is `null` because a host routes to this: whatever it renders\n * around the route is already on screen, and a second spinner inside it would\n * be one more thing appearing and disappearing during a navigation the host is\n * already indicating.\n */\nexport function lazyPreferencesPage(\n parts: PreferencesPageParts,\n): ComponentType<PreferencesScreenProps> {\n const Bound = lazy(async () => {\n const { PreferencesScreen } = await import('./preferences-screen');\n return {\n default: (props: PreferencesScreenProps): JSX.Element => (\n <PreferencesScreen {...props} {...parts} />\n ),\n };\n });\n\n return function NotificationsPreferencesPage(props: PreferencesScreenProps): JSX.Element {\n return (\n <Suspense fallback={null}>\n <Bound {...props} />\n </Suspense>\n );\n };\n}\n"],"mappings":"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","names":["useState","jsx","Suspense","lazy","jsx","lazy","Suspense","jsx","useState"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/live.ts
|
|
6
|
+
function liveActivityLane(activity) {
|
|
7
|
+
const activeStepId = activity.activeStepId;
|
|
8
|
+
if (activeStepId === null || activity.steps.length === 0) return null;
|
|
9
|
+
const reached = activity.steps.findIndex((step) => step.id === activeStepId);
|
|
10
|
+
if (reached < 0) return null;
|
|
11
|
+
return {
|
|
12
|
+
steps: activity.steps,
|
|
13
|
+
activeStepId,
|
|
14
|
+
completed: new Set(activity.steps.slice(0, reached).map((step) => step.id))
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
__name(liveActivityLane, "liveActivityLane");
|
|
18
|
+
var LIVE_SUBJECT_KEY = "liveSubject";
|
|
19
|
+
var LIVE_PUSH_TAG_PREFIX = "live:";
|
|
20
|
+
function livePushTag(data) {
|
|
21
|
+
const subject = data?.[LIVE_SUBJECT_KEY];
|
|
22
|
+
if (typeof subject !== "string" || subject === "") return null;
|
|
23
|
+
return `${LIVE_PUSH_TAG_PREFIX}${subject}`;
|
|
24
|
+
}
|
|
25
|
+
__name(livePushTag, "livePushTag");
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
liveActivityLane,
|
|
29
|
+
LIVE_SUBJECT_KEY,
|
|
30
|
+
LIVE_PUSH_TAG_PREFIX,
|
|
31
|
+
livePushTag
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=chunk-RTURLH5U.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/live.ts"],"sourcesContent":["/**\n * LIVE ACTIVITIES — the notification centre's second kind of entry.\n *\n * An inbox notification is an EVENT: it happened, it is stamped, it is read or\n * unread, and it is still there tomorrow saying the same thing. A live activity\n * is ONGOING STATE: it is pinned above the list, it has no read/unread, nobody\n * deletes it, it updates itself, and it is GONE the moment the thing it tracks\n * finishes.\n *\n * The distinction is not cosmetic. A row that says \"your order is on its way\",\n * read an hour later, is a claim about the past presented as news — and the\n * more reliable the inbox is, the more of those a person accumulates. Somewhere\n * in that list is the one question they actually have, which is *where is it\n * now*. A live activity answers that question and then removes itself, which is\n * the property no amount of better event copy can buy.\n *\n * ## This file is domain-free, and that is the whole design\n *\n * Nothing here knows what is being tracked. A host publishes activities through\n * `LiveActivitiesConfig.useActivities` (see `./react/live-config`) and this\n * package owns the contract, the placement, the rendering and the OS-level\n * collapse. The package that raises an alert has never been this package's\n * business — see the `generators` seam — and the thing an alert is ABOUT is not\n * either.\n *\n * ## The lane\n *\n * `steps` + `activeStepId` describe a progress lane, because \"how far along is\n * this\" is the shape almost every ongoing subject has. Both are optional in\n * effect: an activity with no lane (`steps: []`, `activeStepId: null`) is a\n * perfectly good live entry — a heading, a sentence and a timestamp that keeps\n * moving.\n */\n\n/** One stop on a live activity's lane. */\nexport interface LiveActivityStep {\n /** Stable across updates — the id is what `activeStepId` names. */\n id: string;\n /** What the reader sees under the dot. The HOST's word, in the reader's language. */\n label: string;\n}\n\n/** One thing that is happening right now, as the notification centre shows it. */\nexport interface LiveActivity {\n /**\n * Stable for the whole life of the subject.\n *\n * The SAME id on every update, because it is the identity of the thing being\n * tracked and not of the message: the panel keys on it so a stage change\n * re-renders one card rather than swapping two, and it is what\n * {@link livePushTag} collapses an OS notification onto.\n *\n * **Unique among the activities live at one moment**, for the same reason:\n * it is the React key and the card's test id. Two activities sharing one\n * gives a duplicate-key warning and a card that silently shows the wrong\n * subject.\n */\n id: string;\n /**\n * Which host concern this belongs to, e.g. `order`, `delivery`, `import`.\n *\n * Free-form and never rendered — it exists so a host publishing from two\n * sources can tell its own activities apart in a test or a log without\n * parsing `id`.\n */\n kind: string;\n /** The heading — what is happening. */\n title: string;\n /** The line under it: the detail that makes the heading actionable, or none. */\n body: string | null;\n /** Where tapping the card goes, as a same-origin path. `null` renders no link. */\n link: string | null;\n /** The lane, in the order it is walked. Empty when this subject has no lane. */\n steps: readonly LiveActivityStep[];\n /**\n * The stop the subject is AT — one of `steps`, or `null` for a laneless entry.\n *\n * An id that names no step is neither, and it is handled rather than trusted:\n * see {@link liveActivityLane}.\n */\n activeStepId: string | null;\n /**\n * ISO-8601 — when the subject last MOVED, not when it was last polled.\n *\n * A timestamp that advances on every read would render \"just now\" forever,\n * which is the one thing a live entry must not say when nothing is happening.\n */\n updatedAt: string;\n}\n\n/** A live activity's lane, resolved for rendering. */\nexport interface LiveActivityLane {\n steps: readonly LiveActivityStep[];\n activeStepId: string;\n /** Every stop BEFORE the active one — never the active one itself. */\n completed: ReadonlySet<string>;\n}\n\n/**\n * The lane to draw, or `null` when this activity has none to draw.\n *\n * Three cases collapse to `null`, and the third is the one worth writing down:\n * no steps, no active step, and **an active step the lane does not contain**.\n * That last one is not a theoretical defect — it is what a host produces the\n * first time a subject reaches a stage the lane was filtered to exclude, and\n * the symptom is a row of dots with NONE of them lit, which reads as a process\n * that has stopped. A card with no lane still says what is happening and when\n * it last moved; a dead lane says the tracking is broken.\n *\n * `completed` deliberately stops short of the active stop: a completed step\n * draws a tick, and ticking the stop the subject is sitting in claims it has\n * already left.\n */\nexport function liveActivityLane(activity: LiveActivity): LiveActivityLane | null {\n const activeStepId = activity.activeStepId;\n if (activeStepId === null || activity.steps.length === 0) return null;\n const reached = activity.steps.findIndex((step) => step.id === activeStepId);\n if (reached < 0) return null;\n return {\n steps: activity.steps,\n activeStepId,\n completed: new Set(activity.steps.slice(0, reached).map((step) => step.id)),\n };\n}\n\n/**\n * The reserved `data` key that ties a NOTIFICATION to a live subject.\n *\n * A notification and a live activity are different objects with different\n * lifetimes, and this is the only thing that joins them: a generator whose\n * event is about something also tracked live puts the activity's id here, and\n * every channel that can collapse gets to.\n *\n * data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }\n *\n * A plain string on purpose. An object here would grow a second wire contract\n * inside a column this package stores verbatim.\n */\nexport const LIVE_SUBJECT_KEY = 'liveSubject';\n\n/**\n * Namespaces the tray tag, so a live tag does not collide with the ones a host\n * already uses.\n *\n * A convention rather than an enforcement — nothing stops a host emitting its\n * own `live:`-prefixed tags — but it means the two id spaces have to be made to\n * meet rather than meeting by accident.\n */\nexport const LIVE_PUSH_TAG_PREFIX = 'live:';\n\n/**\n * The OS notification tag for a push about a live subject, or `null` for an\n * ordinary event.\n *\n * ## What the tag buys, and why it is the PWA half of this feature\n *\n * A `tag` makes a new notification REPLACE the one already in the tray instead\n * of stacking under it, and — unless the sender asks otherwise — replace it\n * *silently*. So a subject that moves through four stages costs one tray entry\n * and one buzz, and the entry that remains is the CURRENT one. Without it a\n * phone accumulates one alert per stage, all of them still asserting a stage\n * the subject has since left; the freshest is at the top and the reader has to\n * work out that the three below it are history.\n *\n * That is as close as the web platform gets to an ongoing/live notification,\n * and it is the half that reaches a person who does not have the app open —\n * which is most of the time a live activity is live.\n *\n * The id is passed through unchanged: it is the host's, it is already unique\n * per subject, and a hash would make the tray impossible to reason about from a\n * log line.\n */\nexport function livePushTag(\n data: Readonly<Record<string, unknown>> | null | undefined,\n): string | null {\n const subject = data?.[LIVE_SUBJECT_KEY];\n if (typeof subject !== 'string' || subject === '') return null;\n return `${LIVE_PUSH_TAG_PREFIX}${subject}`;\n}\n"],"mappings":";;;;;AAiHO,SAAS,iBAAiB,UAAiD;AAChF,QAAM,eAAe,SAAS;AAC9B,MAAI,iBAAiB,QAAQ,SAAS,MAAM,WAAW,EAAG,QAAO;AACjE,QAAM,UAAU,SAAS,MAAM,UAAU,CAAC,SAAS,KAAK,OAAO,YAAY;AAC3E,MAAI,UAAU,EAAG,QAAO;AACxB,SAAO;AAAA,IACL,OAAO,SAAS;AAAA,IAChB;AAAA,IACA,WAAW,IAAI,IAAI,SAAS,MAAM,MAAM,GAAG,OAAO,EAAE,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AAAA,EAC5E;AACF;AAVgB;AAyBT,IAAM,mBAAmB;AAUzB,IAAM,uBAAuB;AAwB7B,SAAS,YACd,MACe;AACf,QAAM,UAAU,OAAO,gBAAgB;AACvC,MAAI,OAAO,YAAY,YAAY,YAAY,GAAI,QAAO;AAC1D,SAAO,GAAG,oBAAoB,GAAG,OAAO;AAC1C;AANgB;","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
NOTIFICATION_CHANNELS
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-6W7INOYQ.js";
|
|
4
4
|
import {
|
|
5
5
|
__name
|
|
6
6
|
} from "./chunk-7QVYU63E.js";
|
|
@@ -131,4 +131,4 @@ export {
|
|
|
131
131
|
normalizePhoneE164,
|
|
132
132
|
inboxWire
|
|
133
133
|
};
|
|
134
|
-
//# sourceMappingURL=chunk-
|
|
134
|
+
//# sourceMappingURL=chunk-SWOWHIFE.js.map
|
|
@@ -7,14 +7,17 @@ import {
|
|
|
7
7
|
mergeChoices,
|
|
8
8
|
mergeStoredRow,
|
|
9
9
|
normalizePhoneE164
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-SWOWHIFE.js";
|
|
11
11
|
import {
|
|
12
12
|
messagesOf
|
|
13
13
|
} from "./chunk-M2TVBVH2.js";
|
|
14
|
+
import {
|
|
15
|
+
livePushTag
|
|
16
|
+
} from "./chunk-RTURLH5U.js";
|
|
14
17
|
import {
|
|
15
18
|
NOTIFICATION_CHANNELS,
|
|
16
19
|
taxonomyOf
|
|
17
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-6W7INOYQ.js";
|
|
18
21
|
import {
|
|
19
22
|
renderEmail
|
|
20
23
|
} from "./chunk-EKUSNUBT.js";
|
|
@@ -419,7 +422,8 @@ function formatWebPush(content) {
|
|
|
419
422
|
title: content.title,
|
|
420
423
|
body: content.body,
|
|
421
424
|
link: content.link ?? null,
|
|
422
|
-
data: content.data ?? {}
|
|
425
|
+
data: content.data ?? {},
|
|
426
|
+
tag: livePushTag(content.data)
|
|
423
427
|
};
|
|
424
428
|
}
|
|
425
429
|
__name(formatWebPush, "formatWebPush");
|
|
@@ -1349,4 +1353,4 @@ export {
|
|
|
1349
1353
|
createTransportRegistry,
|
|
1350
1354
|
createApiNotifications
|
|
1351
1355
|
};
|
|
1352
|
-
//# sourceMappingURL=chunk-
|
|
1356
|
+
//# sourceMappingURL=chunk-WVRODNXQ.js.map
|