@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.
Files changed (59) hide show
  1. package/ADOPTING.md +32 -0
  2. package/dist/{chunk-TIGTBSAQ.js → chunk-7234ROOP.js} +11 -5
  3. package/dist/chunk-7234ROOP.js.map +1 -0
  4. package/dist/{chunk-Y34FX24X.js → chunk-BGI4BQ66.js} +2 -10
  5. package/dist/chunk-BGI4BQ66.js.map +1 -0
  6. package/dist/chunk-BW723CX2.js +214 -0
  7. package/dist/chunk-BW723CX2.js.map +1 -0
  8. package/dist/chunk-CQZMTFPY.js +76 -0
  9. package/dist/chunk-CQZMTFPY.js.map +1 -0
  10. package/dist/{chunk-HQU4R4SG.js → chunk-GGAZOLNN.js} +2 -2
  11. package/dist/{chunk-QRAXX3GR.js → chunk-KU4FVOFA.js} +2 -2
  12. package/dist/chunk-M2TVBVH2.js +15 -0
  13. package/dist/chunk-M2TVBVH2.js.map +1 -0
  14. package/dist/chunk-M5XTROYM.js +263 -0
  15. package/dist/chunk-M5XTROYM.js.map +1 -0
  16. package/dist/chunk-O5BVUXPO.js +22 -0
  17. package/dist/chunk-O5BVUXPO.js.map +1 -0
  18. package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-WaexrZnn.d.ts} +12 -4
  19. package/dist/{create-web-notifications-BpNR8qH3.d.ts → create-web-notifications-DrgqtU4Q.d.ts} +15 -4
  20. package/dist/{generators-B9xt3sRh.d.ts → generators-CH5m8Ucj.d.ts} +1 -1
  21. package/dist/hono/index.d.ts +5 -5
  22. package/dist/hono/index.js +4 -3
  23. package/dist/hono/index.js.map +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +5 -3
  26. package/dist/{jobs-BDZ7aGHV.d.ts → jobs-CcsSA0YR.d.ts} +1 -1
  27. package/dist/manifest/server.d.ts +6 -6
  28. package/dist/manifest/server.js +5 -4
  29. package/dist/manifest/server.js.map +1 -1
  30. package/dist/manifest/web.d.ts +3 -3
  31. package/dist/manifest/web.js +3 -2
  32. package/dist/manifest/web.js.map +1 -1
  33. package/dist/panel-UFXNO4AF.js +243 -0
  34. package/dist/panel-UFXNO4AF.js.map +1 -0
  35. package/dist/preferences-screen-V3ZBWP7A.js +294 -0
  36. package/dist/preferences-screen-V3ZBWP7A.js.map +1 -0
  37. package/dist/react/index.d.ts +4 -4
  38. package/dist/react/index.js +17 -11
  39. package/dist/server/index.d.ts +7 -7
  40. package/dist/server/index.js +5 -4
  41. package/dist/{types-CXLAG3UU.d.ts → types-CYJN1xCM.d.ts} +39 -2
  42. package/dist/web-push/index.d.ts +2 -2
  43. package/dist/{web-push-Cs14Wp9u.d.ts → web-push-iER5StBm.d.ts} +1 -1
  44. package/dist/{wire-6dzyfDE7.d.ts → wire-CmRrB3_s.d.ts} +1 -1
  45. package/package.json +3 -3
  46. package/src/index.ts +1 -0
  47. package/src/react/create-web-notifications.tsx +19 -10
  48. package/src/react/page-lazy.tsx +73 -0
  49. package/src/react/panel-lazy.tsx +74 -0
  50. package/src/server/db.ts +12 -1
  51. package/src/server/dispatch.ts +4 -0
  52. package/src/server/router.ts +17 -1
  53. package/src/types.ts +39 -1
  54. package/dist/chunk-6HLHQDKS.js +0 -1022
  55. package/dist/chunk-6HLHQDKS.js.map +0 -1
  56. package/dist/chunk-TIGTBSAQ.js.map +0 -1
  57. package/dist/chunk-Y34FX24X.js.map +0 -1
  58. /package/dist/{chunk-HQU4R4SG.js.map → chunk-GGAZOLNN.js.map} +0 -0
  59. /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 { NotificationsPanel, type NotificationsPanelProps } from './panel';
14
- import { PreferencesScreen, type PreferencesScreenProps } from './preferences-screen';
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
- /** The routed surface: the preferences screen. */
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
- /** The inbox slide-over, sharing that store. */
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: ComponentType<NotificationsPanelProps> = (props) => (
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: (props) => (
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<{ email: string | null; phone: string | null } | null>;
258
+ getContact(userId: string): Promise<{
259
+ email: string | null;
260
+ phone: string | null;
261
+ locale?: string | null;
262
+ } | null>;
252
263
  }
@@ -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
  }
@@ -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
- generate: (payload: TPayload) => NotificationContent;
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. */