@12-apps/notifications 4.7.0 → 4.8.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 +32 -0
- package/dist/{chunk-TIGTBSAQ.js → chunk-7234ROOP.js} +11 -5
- package/dist/chunk-7234ROOP.js.map +1 -0
- package/dist/{chunk-Y34FX24X.js → chunk-BGI4BQ66.js} +2 -10
- package/dist/chunk-BGI4BQ66.js.map +1 -0
- package/dist/chunk-BW723CX2.js +214 -0
- package/dist/chunk-BW723CX2.js.map +1 -0
- package/dist/chunk-CQZMTFPY.js +76 -0
- package/dist/chunk-CQZMTFPY.js.map +1 -0
- package/dist/{chunk-HQU4R4SG.js → chunk-GGAZOLNN.js} +2 -2
- package/dist/{chunk-QRAXX3GR.js → chunk-KU4FVOFA.js} +2 -2
- package/dist/chunk-M2TVBVH2.js +15 -0
- package/dist/chunk-M2TVBVH2.js.map +1 -0
- package/dist/chunk-M5XTROYM.js +263 -0
- package/dist/chunk-M5XTROYM.js.map +1 -0
- package/dist/chunk-O5BVUXPO.js +22 -0
- package/dist/chunk-O5BVUXPO.js.map +1 -0
- package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-WaexrZnn.d.ts} +12 -4
- package/dist/{create-web-notifications-BpNR8qH3.d.ts → create-web-notifications-DrgqtU4Q.d.ts} +15 -4
- package/dist/{generators-B9xt3sRh.d.ts → generators-CH5m8Ucj.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 +3 -3
- package/dist/index.js +5 -3
- package/dist/{jobs-BDZ7aGHV.d.ts → jobs-CcsSA0YR.d.ts} +1 -1
- 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 +3 -3
- package/dist/manifest/web.js +3 -2
- package/dist/manifest/web.js.map +1 -1
- package/dist/panel-UFXNO4AF.js +243 -0
- package/dist/panel-UFXNO4AF.js.map +1 -0
- package/dist/preferences-screen-V3ZBWP7A.js +294 -0
- package/dist/preferences-screen-V3ZBWP7A.js.map +1 -0
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +17 -11
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +5 -4
- package/dist/{types-CXLAG3UU.d.ts → types-CYJN1xCM.d.ts} +39 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-Cs14Wp9u.d.ts → web-push-iER5StBm.d.ts} +1 -1
- package/dist/{wire-6dzyfDE7.d.ts → wire-CmRrB3_s.d.ts} +1 -1
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/react/create-web-notifications.tsx +19 -10
- package/src/react/page-lazy.tsx +73 -0
- package/src/react/panel-lazy.tsx +74 -0
- package/src/server/db.ts +12 -1
- package/src/server/dispatch.ts +4 -0
- package/src/server/router.ts +17 -1
- package/src/types.ts +39 -1
- package/dist/chunk-6HLHQDKS.js +0 -1022
- package/dist/chunk-6HLHQDKS.js.map +0 -1
- package/dist/chunk-TIGTBSAQ.js.map +0 -1
- package/dist/chunk-Y34FX24X.js.map +0 -1
- /package/dist/{chunk-HQU4R4SG.js.map → chunk-GGAZOLNN.js.map} +0 -0
- /package/dist/{chunk-QRAXX3GR.js.map → chunk-KU4FVOFA.js.map} +0 -0
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
type NotificationsSubscribe,
|
|
11
11
|
} from './hooks';
|
|
12
12
|
import { createInboxStore, type InboxStore } from './inbox-state';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { lazyNotificationsPanel } from './panel-lazy';
|
|
14
|
+
import type { NotificationsPanelProps } from './panel';
|
|
15
|
+
import { lazyPreferencesPage } from './page-lazy';
|
|
16
|
+
import type { PreferencesScreenProps } from './preferences-screen';
|
|
15
17
|
import { httpNotificationsTransport, type NotificationsTransport } from './transport';
|
|
16
18
|
import type { WebPushSetupConfig } from './web-push-setup';
|
|
17
19
|
|
|
@@ -56,11 +58,22 @@ export interface NotificationsWebConfig {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export interface WebNotifications {
|
|
59
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The routed surface: the preferences screen.
|
|
63
|
+
*
|
|
64
|
+
* Loaded on demand — see `page-lazy.tsx`. A host that mounts only the bell and
|
|
65
|
+
* the panel never downloads it, and a host that routes to it fetches it while
|
|
66
|
+
* entering that route.
|
|
67
|
+
*/
|
|
60
68
|
page: ComponentType<PreferencesScreenProps>;
|
|
61
69
|
/** The bell, already bound to the shared store. */
|
|
62
70
|
BellButton: ComponentType<BellButtonProps>;
|
|
63
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The inbox slide-over, sharing that store.
|
|
73
|
+
*
|
|
74
|
+
* Loaded the first time it is opened — see `panel-lazy.tsx`. Until then a
|
|
75
|
+
* host's chrome carries the bell and nothing else.
|
|
76
|
+
*/
|
|
64
77
|
Panel: ComponentType<NotificationsPanelProps>;
|
|
65
78
|
/**
|
|
66
79
|
* Bell + panel as ONE element, for a host that just wants the feature in its
|
|
@@ -97,9 +110,7 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
|
|
|
97
110
|
const Bell: ComponentType<BellButtonProps> = (props) => (
|
|
98
111
|
<BellButton {...props} store={store} messages={messages} {...subscribeOption} />
|
|
99
112
|
);
|
|
100
|
-
const Panel
|
|
101
|
-
<NotificationsPanel {...props} store={store} messages={messages} />
|
|
102
|
-
);
|
|
113
|
+
const Panel = lazyNotificationsPanel({ store, messages });
|
|
103
114
|
|
|
104
115
|
function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
|
|
105
116
|
return useUnreadCount(store, { ...options, ...subscribeOption });
|
|
@@ -126,9 +137,7 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
|
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
return {
|
|
129
|
-
page: (
|
|
130
|
-
<PreferencesScreen {...props} api={api} messages={messages} webPush={webPush} />
|
|
131
|
-
),
|
|
140
|
+
page: lazyPreferencesPage({ api, messages, webPush }),
|
|
132
141
|
BellButton: Bell,
|
|
133
142
|
Panel,
|
|
134
143
|
BellWithPanel,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routed preferences screen, fetched when a host actually routes to it.
|
|
3
|
+
*
|
|
4
|
+
* `createWebNotifications` returns two different KINDS of thing, and its own
|
|
5
|
+
* docstring says so: `page` is "the standalone surface … the one thing a host
|
|
6
|
+
* routes to", while the bell and the panel "are a PAIR a host drops into its own
|
|
7
|
+
* chrome". Chrome is on screen from the first paint; a routed surface is not.
|
|
8
|
+
*
|
|
9
|
+
* A static import made that distinction invisible to a bundler. Every host that
|
|
10
|
+
* put the bell in its header also shipped the preferences matrix — its channel
|
|
11
|
+
* toggles, the per-browser push enable step, and the design-system `Switch`
|
|
12
|
+
* behind them — in the same chunk as the header. A storefront paid for a
|
|
13
|
+
* settings screen a shopper never opens, before its first screen could render;
|
|
14
|
+
* a host that renders its OWN preferences page paid for this one twice.
|
|
15
|
+
*
|
|
16
|
+
* So `page` now loads on demand. Nothing else moves: the bell, the panel and
|
|
17
|
+
* `BellWithPanel` stay exactly as eager as the chrome they belong to, because
|
|
18
|
+
* that is what they are.
|
|
19
|
+
*
|
|
20
|
+
* NO PREFETCH, deliberately, and this is the opposite call from a surface a
|
|
21
|
+
* host opens from chrome it already has. A routed surface is reached by
|
|
22
|
+
* NAVIGATION, and every host here already code-splits its routes — so the
|
|
23
|
+
* fetch happens while the route is being entered, which is the moment a
|
|
24
|
+
* prefetch would have been trying to anticipate. Warming it at factory time
|
|
25
|
+
* would put the screen back on the boot path of every app, which is the whole
|
|
26
|
+
* cost this removes.
|
|
27
|
+
*/
|
|
28
|
+
import { Suspense, lazy, type ComponentType, type JSX } from 'react';
|
|
29
|
+
|
|
30
|
+
import type { NotificationMessages } from '../messages';
|
|
31
|
+
|
|
32
|
+
import type { NotificationsApiClient } from './api';
|
|
33
|
+
import type { PreferencesScreenProps } from './preferences-screen';
|
|
34
|
+
import type { WebPushSetupConfig } from './web-push-setup';
|
|
35
|
+
|
|
36
|
+
/** What the factory binds into the screen, and the host never passes. */
|
|
37
|
+
interface PreferencesPageParts {
|
|
38
|
+
api: NotificationsApiClient;
|
|
39
|
+
messages: NotificationMessages;
|
|
40
|
+
webPush: WebPushSetupConfig;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The routed screen, bound and loaded on first render.
|
|
45
|
+
*
|
|
46
|
+
* `lazy` memoises its factory, so the binding below happens once however many
|
|
47
|
+
* times a host mounts the page — the same guarantee the direct call gave.
|
|
48
|
+
*
|
|
49
|
+
* The fallback is `null` because a host routes to this: whatever it renders
|
|
50
|
+
* around the route is already on screen, and a second spinner inside it would
|
|
51
|
+
* be one more thing appearing and disappearing during a navigation the host is
|
|
52
|
+
* already indicating.
|
|
53
|
+
*/
|
|
54
|
+
export function lazyPreferencesPage(
|
|
55
|
+
parts: PreferencesPageParts,
|
|
56
|
+
): ComponentType<PreferencesScreenProps> {
|
|
57
|
+
const Bound = lazy(async () => {
|
|
58
|
+
const { PreferencesScreen } = await import('./preferences-screen');
|
|
59
|
+
return {
|
|
60
|
+
default: (props: PreferencesScreenProps): JSX.Element => (
|
|
61
|
+
<PreferencesScreen {...props} {...parts} />
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return function NotificationsPreferencesPage(props: PreferencesScreenProps): JSX.Element {
|
|
67
|
+
return (
|
|
68
|
+
<Suspense fallback={null}>
|
|
69
|
+
<Bound {...props} />
|
|
70
|
+
</Suspense>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox slide-over, fetched the first time somebody opens it.
|
|
3
|
+
*
|
|
4
|
+
* The bell and the panel are a PAIR a host drops into its chrome, and that is
|
|
5
|
+
* still true — but only the BELL is on screen when a page paints. The panel is
|
|
6
|
+
* behind a tap, and a static import made every host pay for it up front: the
|
|
7
|
+
* design-system `Drawer` and, through it, MUI's `SwipeableDrawer`, `Modal`,
|
|
8
|
+
* `Slide` and the focus trap, plus the row, the empty state and the pager. On a
|
|
9
|
+
* storefront that is a slide-over most visits never open, parsed before the
|
|
10
|
+
* first screen can render.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the gate is "ever opened" rather than `open`
|
|
13
|
+
*
|
|
14
|
+
* `lazy` fetches when a component first RENDERS, so a boundary that still
|
|
15
|
+
* rendered the panel while closed would fetch immediately and buy nothing. This
|
|
16
|
+
* renders `null` until the panel has been open once, which is what actually
|
|
17
|
+
* defers the download to the tap.
|
|
18
|
+
*
|
|
19
|
+
* And once opened it STAYS mounted. Unmounting on close would throw away the
|
|
20
|
+
* drawer's transition state, so the panel would vanish instead of sliding out,
|
|
21
|
+
* and the entrance animation would re-run on every reopen — which someone
|
|
22
|
+
* working through an inbox does repeatedly. The fetch happens once.
|
|
23
|
+
*
|
|
24
|
+
* The initial state reads `open` rather than starting at `false`, so a host that
|
|
25
|
+
* mounts the panel already open renders it in the same commit instead of a frame
|
|
26
|
+
* later.
|
|
27
|
+
*
|
|
28
|
+
* ## Why `null` for the fallback
|
|
29
|
+
*
|
|
30
|
+
* The only frame this can show anything is the one right after the tap, where a
|
|
31
|
+
* spinner reads as a stall rather than as progress. The chunk is small and
|
|
32
|
+
* same-origin.
|
|
33
|
+
*/
|
|
34
|
+
import { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } from 'react';
|
|
35
|
+
|
|
36
|
+
import type { NotificationMessages } from '../messages';
|
|
37
|
+
|
|
38
|
+
import type { InboxStore } from './inbox-state';
|
|
39
|
+
import type { NotificationsPanelProps } from './panel';
|
|
40
|
+
|
|
41
|
+
/** What the factory binds into the panel, and the host never passes. */
|
|
42
|
+
interface PanelParts {
|
|
43
|
+
store: InboxStore;
|
|
44
|
+
messages: NotificationMessages;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function lazyNotificationsPanel(
|
|
48
|
+
parts: PanelParts,
|
|
49
|
+
): ComponentType<NotificationsPanelProps> {
|
|
50
|
+
const Bound = lazy(async () => {
|
|
51
|
+
const { NotificationsPanel } = await import('./panel');
|
|
52
|
+
return {
|
|
53
|
+
default: (props: NotificationsPanelProps): JSX.Element => (
|
|
54
|
+
<NotificationsPanel {...props} {...parts} />
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return function NotificationsPanelSlot(props: NotificationsPanelProps): JSX.Element | null {
|
|
60
|
+
const [everOpened, setEverOpened] = useState(props.open);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (props.open) setEverOpened(true);
|
|
64
|
+
}, [props.open]);
|
|
65
|
+
|
|
66
|
+
if (!everOpened) return null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Suspense fallback={null}>
|
|
70
|
+
<Bound {...props} />
|
|
71
|
+
</Suspense>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/server/db.ts
CHANGED
|
@@ -246,7 +246,18 @@ export type NotificationsDbProvider = () => Promise<NotificationsDb>;
|
|
|
246
246
|
* Returning `null` for a user id means "no such recipient", which `notify`
|
|
247
247
|
* treats as a caller bug and throws on — a notification addressed to nobody is
|
|
248
248
|
* never silently dropped.
|
|
249
|
+
*
|
|
250
|
+
* `locale` is the recipient's own language, and it is here rather than on the
|
|
251
|
+
* event because it is a fact about the PERSON, not about what happened
|
|
252
|
+
* The host owns it for the same reason it owns the address: this
|
|
253
|
+
* package has no user table to read it from. Absent — the field omitted, or
|
|
254
|
+
* `null` for a host that stores no language yet — means "nobody said", and
|
|
255
|
+
* every generator answers with its default exactly as before.
|
|
249
256
|
*/
|
|
250
257
|
export interface NotificationContactDirectory {
|
|
251
|
-
getContact(userId: string): Promise<{
|
|
258
|
+
getContact(userId: string): Promise<{
|
|
259
|
+
email: string | null;
|
|
260
|
+
phone: string | null;
|
|
261
|
+
locale?: string | null;
|
|
262
|
+
} | null>;
|
|
252
263
|
}
|
package/src/server/dispatch.ts
CHANGED
|
@@ -99,6 +99,10 @@ export async function loadRecipient(
|
|
|
99
99
|
userId,
|
|
100
100
|
email: contact.email,
|
|
101
101
|
phone: contact.phone,
|
|
102
|
+
// Carried through UNRESOLVED and only where the host supplied one: the
|
|
103
|
+
// absent case has to stay distinguishable from a stated language, because
|
|
104
|
+
// that is what lets a generator apply its own default in one place.
|
|
105
|
+
...(contact.locale === undefined ? {} : { locale: contact.locale }),
|
|
102
106
|
pushSubscriptionCount: await deps.pushSubscriptions.count(userId),
|
|
103
107
|
};
|
|
104
108
|
}
|
package/src/server/router.ts
CHANGED
|
@@ -235,11 +235,27 @@ export function createNotificationRouter(deps: NotificationRouterDeps): Notifica
|
|
|
235
235
|
|
|
236
236
|
async notify(event, options = {}) {
|
|
237
237
|
const generator = deps.generators.resolve(event.type);
|
|
238
|
-
const content = generator.generate(event.payload as never);
|
|
239
238
|
|
|
239
|
+
/*
|
|
240
|
+
The recipient is loaded BEFORE the content is rendered, and the order is
|
|
241
|
+
the whole of the fix.
|
|
242
|
+
|
|
243
|
+
`notify` is already per-person — `event.recipient.userId` is one user,
|
|
244
|
+
and a permission fan-out resolves its audience and calls this once each
|
|
245
|
+
— so the reader was knowable here all along. Rendering first simply
|
|
246
|
+
threw that away: the row was written in whatever language the generator
|
|
247
|
+
had been bound to at boot, for everyone.
|
|
248
|
+
|
|
249
|
+
Loading first also means a notification addressed to nobody now throws
|
|
250
|
+
before any content is built, which is the cheaper order anyway.
|
|
251
|
+
*/
|
|
240
252
|
const recipient = await loadRecipient(deps, event.recipient.userId);
|
|
241
253
|
if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
|
|
242
254
|
|
|
255
|
+
// Forwarded exactly as the directory stated it, `undefined` included: the
|
|
256
|
+
// generator owns the fallback, in one place a reader can find.
|
|
257
|
+
const content = generator.generate(event.payload as never, { locale: recipient.locale });
|
|
258
|
+
|
|
243
259
|
const channels = await resolveChannels(deps, event, generator.category, recipient);
|
|
244
260
|
const notification = await commit(deps, event, generator.category, content, channels);
|
|
245
261
|
|
package/src/types.ts
CHANGED
|
@@ -91,12 +91,45 @@ export interface NotificationEvent<TPayload = unknown> {
|
|
|
91
91
|
* generator never touches existing generators, the router, or any transport
|
|
92
92
|
* (open/closed).
|
|
93
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* Who the content is being rendered FOR — the reader, at the moment the
|
|
96
|
+
* generator is asked.
|
|
97
|
+
*
|
|
98
|
+
* A notification is stored as rendered TEXT: title and body are columns, so
|
|
99
|
+
* the language is chosen once, when the row is written, and never again. That
|
|
100
|
+
* makes this the only honest place to ask. A generator is registered at BOOT —
|
|
101
|
+
* a host that resolved its words there would pin every future reader to
|
|
102
|
+
* whichever language the process happened to start in, invisibly, because a
|
|
103
|
+
* single-locale host cannot tell the difference.
|
|
104
|
+
*
|
|
105
|
+
* The tag is the RECIPIENT's, never the request's. The person who triggers a
|
|
106
|
+
* notification is routinely not the person who reads it: an invite is sent
|
|
107
|
+
* because an administrator acted and is read by the invitee. Reading
|
|
108
|
+
* `Accept-Language` here would be a bug that only ever surfaces as somebody
|
|
109
|
+
* being told things in a language they do not speak.
|
|
110
|
+
*
|
|
111
|
+
* Absent means "nobody said" — a host with one audience, or one that stores no
|
|
112
|
+
* per-user language, populates nothing and every generator answers with its
|
|
113
|
+
* own default exactly as it did before this existed.
|
|
114
|
+
*/
|
|
115
|
+
export interface NotificationGenerateContext {
|
|
116
|
+
readonly locale?: string | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
export interface NotificationGenerator<TPayload = unknown> {
|
|
95
120
|
/** The event key, dot-namespaced ("order.paid"). One generator per type. */
|
|
96
121
|
type: string;
|
|
97
122
|
/** The preference category the router gates this type's fan-out on. */
|
|
98
123
|
category: NotificationCategory;
|
|
99
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Render this event's content for ONE recipient.
|
|
126
|
+
*
|
|
127
|
+
* `context` is OPTIONAL, and that is what keeps every generator written
|
|
128
|
+
* before it working: a one-parameter `generate` is assignable to this
|
|
129
|
+
* signature unchanged. A host that passes nothing is stating a fact — it has
|
|
130
|
+
* no language for this reader — rather than asserting a default.
|
|
131
|
+
*/
|
|
132
|
+
generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;
|
|
100
133
|
}
|
|
101
134
|
|
|
102
135
|
/**
|
|
@@ -108,6 +141,11 @@ export interface NotificationGenerator<TPayload = unknown> {
|
|
|
108
141
|
export interface TransportRecipient {
|
|
109
142
|
userId: string;
|
|
110
143
|
email: string | null;
|
|
144
|
+
/**
|
|
145
|
+
* The recipient's own language, when the host's contact directory states
|
|
146
|
+
* one. Absent means "nobody said" — see {@link NotificationGenerateContext}.
|
|
147
|
+
*/
|
|
148
|
+
locale?: string | null;
|
|
111
149
|
/** Phone as the host stores it (transports normalize per provider rules). */
|
|
112
150
|
phone: string | null;
|
|
113
151
|
/** How many active browser push subscriptions the user holds. */
|