@12-apps/notifications 4.10.3 → 4.12.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 (51) hide show
  1. package/ADOPTING.md +31 -2
  2. package/dist/{chunk-BW723CX2.js → chunk-2IAHFIXS.js} +51 -38
  3. package/dist/chunk-2IAHFIXS.js.map +1 -0
  4. package/dist/{chunk-6W7INOYQ.js → chunk-CPQKKLPS.js} +1 -1
  5. package/dist/{chunk-6W7INOYQ.js.map → chunk-CPQKKLPS.js.map} +1 -1
  6. package/dist/{chunk-ZIR3ILFH.js → chunk-FBBPS2LT.js} +2 -2
  7. package/dist/{chunk-SWOWHIFE.js → chunk-GK6GSC2J.js} +37 -2
  8. package/dist/chunk-GK6GSC2J.js.map +1 -0
  9. package/dist/{chunk-H55A4LHG.js → chunk-PNY6S6WH.js} +39 -28
  10. package/dist/chunk-PNY6S6WH.js.map +1 -0
  11. package/dist/{chunk-WVRODNXQ.js → chunk-WZBX7YCE.js} +32 -15
  12. package/dist/{chunk-WVRODNXQ.js.map → chunk-WZBX7YCE.js.map} +1 -1
  13. package/dist/{create-api-notifications-CcPYrM3p.d.ts → create-api-notifications-BTudlaSC.d.ts} +15 -6
  14. package/dist/{create-web-notifications-DV3Y8k7e.d.ts → create-web-notifications-2xxbKnrW.d.ts} +61 -14
  15. package/dist/{generators-qAD4fNPq.d.ts → generators-CQYdJfB5.d.ts} +1 -1
  16. package/dist/hono/index.d.ts +5 -5
  17. package/dist/hono/index.js +3 -3
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +9 -3
  20. package/dist/{jobs--fex87-q.d.ts → jobs-CaovU4GM.d.ts} +1 -1
  21. package/dist/manifest/server.d.ts +6 -6
  22. package/dist/manifest/server.js +4 -4
  23. package/dist/manifest/web.d.ts +3 -3
  24. package/dist/manifest/web.js +2 -2
  25. package/dist/{panel-V2ULFC4Y.js → panel-MKI4PTNZ.js} +2 -2
  26. package/dist/{preferences-screen-SXUIGECY.js → preferences-screen-S3ZHX5LB.js} +2 -2
  27. package/dist/react/index.d.ts +4 -4
  28. package/dist/react/index.js +2 -2
  29. package/dist/server/index.d.ts +7 -7
  30. package/dist/server/index.js +4 -4
  31. package/dist/{types-BlqZkCWZ.d.ts → types-H_aFzLA0.d.ts} +33 -0
  32. package/dist/web-push/index.d.ts +2 -2
  33. package/dist/{web-push-Dnyaha2z.d.ts → web-push-C6U-5JCV.d.ts} +1 -1
  34. package/dist/{wire-BG1kuoXX.d.ts → wire-Bn6aA2nL.d.ts} +65 -2
  35. package/package.json +2 -2
  36. package/src/index.ts +4 -0
  37. package/src/preferences-core.ts +107 -0
  38. package/src/react/bell-badge.ts +147 -0
  39. package/src/react/bell-button.tsx +19 -31
  40. package/src/react/create-web-notifications.tsx +55 -6
  41. package/src/react/hooks.ts +61 -13
  42. package/src/react/index.ts +13 -0
  43. package/src/server/preferences.ts +29 -9
  44. package/src/server/router.ts +37 -7
  45. package/src/types.ts +33 -0
  46. package/dist/chunk-BW723CX2.js.map +0 -1
  47. package/dist/chunk-H55A4LHG.js.map +0 -1
  48. package/dist/chunk-SWOWHIFE.js.map +0 -1
  49. /package/dist/{chunk-ZIR3ILFH.js.map → chunk-FBBPS2LT.js.map} +0 -0
  50. /package/dist/{panel-V2ULFC4Y.js.map → panel-MKI4PTNZ.js.map} +0 -0
  51. /package/dist/{preferences-screen-SXUIGECY.js.map → preferences-screen-S3ZHX5LB.js.map} +0 -0
@@ -70,11 +70,118 @@ export function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow {
70
70
  return row;
71
71
  }
72
72
 
73
+ /**
74
+ * What ONE notification type says about its own channels, independent of the
75
+ * user's category preferences: {@link NotificationGenerator.channels} (the hard
76
+ * availability cap) and {@link NotificationGenerator.channelDefaults} (a
77
+ * starting point the user can still move).
78
+ *
79
+ * Structurally what a generator already is, rather than the generator itself,
80
+ * so the policy here stays free of the registry and the router.
81
+ */
82
+ export interface TypeChannelRules {
83
+ channels?: readonly NotificationChannel[];
84
+ channelDefaults?: Partial<ChannelRow>;
85
+ }
86
+
87
+ /**
88
+ * The channels a type may EVER use, coerced onto the closed channel set.
89
+ *
90
+ * `undefined` — a generator that never declared a list — means every channel,
91
+ * which is what keeps every generator written before the field working. An
92
+ * EMPTY list means no transport channel at all and is legal: the inbox record
93
+ * is written by the router regardless, and the inbox is not a channel a user
94
+ * opts out of.
95
+ *
96
+ * Filtering through {@link NOTIFICATION_CHANNELS} rather than returning the
97
+ * declaration is deliberate: it drops a value that is not a channel (a typo, a
98
+ * channel removed from the set since) instead of carrying it into an
99
+ * intersection where it would silently match nothing anyway, and it fixes the
100
+ * order so two declarations of the same set compare equal.
101
+ */
102
+ export function availableChannelsOf(
103
+ declared: readonly NotificationChannel[] | undefined,
104
+ ): NotificationChannel[] {
105
+ if (!declared) return [...NOTIFICATION_CHANNELS];
106
+ const offered = new Set<string>(declared);
107
+ return NOTIFICATION_CHANNELS.filter((channel) => offered.has(channel));
108
+ }
109
+
110
+ /**
111
+ * Drop the channels a type does not offer. Applied by the router AFTER every
112
+ * other gate, so no later stage can hand back a channel the type never offered
113
+ * — including the plan gate's own error fallback, which degrades to the free
114
+ * channels and would otherwise restore an e-mail this type had just refused.
115
+ */
116
+ export function capToAvailable(
117
+ channels: readonly NotificationChannel[],
118
+ declared: readonly NotificationChannel[] | undefined,
119
+ ): NotificationChannel[] {
120
+ if (!declared) return [...channels];
121
+ const offered = new Set(availableChannelsOf(declared));
122
+ return channels.filter((channel) => offered.has(channel));
123
+ }
124
+
125
+ /**
126
+ * The channels one (user, category, TYPE) actually enables — the whole policy
127
+ * in one pure function, so the router's gate can be argued about without a
128
+ * database.
129
+ *
130
+ * The order is the meaning:
131
+ * 1. the category's defaults, with the TYPE's defaults over them — a type
132
+ * moves the starting point;
133
+ * 2. the user's stored row over that — an explicit choice beats any default,
134
+ * which is what makes step 1 a default rather than a rule;
135
+ * 3. the type's AVAILABILITY over everything — a channel this type does not
136
+ * offer is gone even when the user's stored row explicitly asked for it,
137
+ * because it was never on offer for this message.
138
+ *
139
+ * Step 3 overriding a stored `true` is the one place a user's saved choice is
140
+ * discarded, and it is the point of the field: the answer they gave was about
141
+ * the CATEGORY, and a category can hold messages this channel was never right
142
+ * for. Step 2 beating step 1 is what keeps the other field a default.
143
+ */
144
+ export function resolveTypeChannels(input: {
145
+ /** The stored `channels` JSON for this (user, category), if any. */
146
+ stored?: unknown;
147
+ /** The category's effective default row (host defaults already merged). */
148
+ categoryDefaults: ChannelRow;
149
+ /** The type's own declarations. */
150
+ rules?: TypeChannelRules;
151
+ }): NotificationChannel[] {
152
+ const { stored, categoryDefaults, rules } = input;
153
+ const base: ChannelRow = { ...categoryDefaults, ...rules?.channelDefaults };
154
+ const row = stored === undefined || stored === null ? base : mergeStoredRow(stored, base);
155
+ return capToAvailable(enabledChannelsOf(row), rules?.channels);
156
+ }
157
+
73
158
  /** The channels enabled by one effective row — the router's gate. */
74
159
  export function enabledChannelsOf(row: ChannelRow): NotificationChannel[] {
75
160
  return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);
76
161
  }
77
162
 
163
+ /**
164
+ * The channels a stored row states an EXPLICIT choice for, dropping everything
165
+ * else — the inverse of {@link mergeStoredRow}, which fills the gaps in.
166
+ *
167
+ * The package's storage model is "only explicit choices are stored; a missing
168
+ * key falls back to the default", and this is what lets a writer keep that
169
+ * promise. Merging a save onto the user's EFFECTIVE row instead turns every
170
+ * defaulted channel into an explicit one the moment they touch any switch, and
171
+ * the row can never say "no opinion" about a channel again.
172
+ */
173
+ export function explicitChoicesOf(stored: unknown): Partial<ChannelRow> {
174
+ const choices: Partial<ChannelRow> = {};
175
+ if (stored && typeof stored === 'object') {
176
+ const record = stored as Record<string, unknown>;
177
+ for (const channel of NOTIFICATION_CHANNELS) {
178
+ const value = record[channel];
179
+ if (typeof value === 'boolean') choices[channel] = value;
180
+ }
181
+ }
182
+ return choices;
183
+ }
184
+
78
185
  /**
79
186
  * What a PUT writes for one category: the caller's toggles merged over the
80
187
  * user's CURRENT effective row. A single-toggle save (how the settings UI
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What the bell shows: ONE number, and whether any of it is news.
3
+ *
4
+ * The badge answers two different questions with one glyph, and keeping them
5
+ * apart is the whole design:
6
+ *
7
+ * - the COUNT is how many things the centre is holding for the reader;
8
+ * - the TONE is whether any of them has happened since they last looked.
9
+ *
10
+ * An inbox row makes those the same question — an unread row is by definition
11
+ * both present and unseen — which is why the distinction did not exist before
12
+ * live activities did. A live activity separates them: a pedido that has been
13
+ * `Preparo` for ten minutes is still worth a `1`, and shouting about it every
14
+ * render is how a badge teaches people to stop reading it.
15
+ *
16
+ * ## This is here so a host can DRAW it
17
+ *
18
+ * The numbers were already correct inside this package's own `BellButton`, and
19
+ * unreachable from a host that cannot take that component — a header whose cart
20
+ * and search buttons are one styled icon-button is importing a second trigger
21
+ * style the moment it does. Such a host had `useUnreadCount` and nothing else,
22
+ * so its bell showed NOTHING while a pinned pedido sat inside the panel it
23
+ * opens. Both bells now read these hooks, so a host cannot drift from what this
24
+ * package renders.
25
+ *
26
+ * ## What it does NOT yet do
27
+ *
28
+ * A live subject usually also writes inbox rows as it moves, and this counts
29
+ * both: a pedido with one unread row about it reads `2`. Subtracting the double
30
+ * needs the server to say which unread rows name which subject, and that was
31
+ * built, reviewed and pulled — for reasons about the CONTRACT rather than the
32
+ * arithmetic, and worth recording so the next attempt starts past them:
33
+ *
34
+ * - it added a field to `GET /notifications/unread-count`, and at least one
35
+ * adopter publishes that response as a closed schema to LLM clients. An
36
+ * additive field is a breaking change against `additionalProperties: false`.
37
+ * - the scan is per READER, so every host paid it — including the two SPAs in
38
+ * that adopter that share one factory and configure no live activities at
39
+ * all, and read the count through `useUnreadCount`, which never sees the
40
+ * breakdown.
41
+ * - it narrowed `NotificationsApiClient.unreadCount()` from `Promise<number>`,
42
+ * which is a breaking change on a commit the release rules cut as a minor.
43
+ *
44
+ * The way through is an opt-in the surface asks for — a host with no live
45
+ * activities then sends nothing different and receives nothing different.
46
+ *
47
+ * (An earlier revision of this docblock blamed a missing index instead. That
48
+ * was wrong: `[userId, deletedAt, readAt]` is a full equality prefix over the
49
+ * filter, and the `ORDER BY` the scan carried was not load-bearing, since a
50
+ * tally does not care what order it counts in.)
51
+ */
52
+ import { useMemo, useSyncExternalStore } from 'react';
53
+
54
+ import { useBadgeState, type BadgeSyncOptions } from './hooks';
55
+ import type { InboxStore } from './inbox-state';
56
+ import type { LiveActivitiesConfig } from './live-config';
57
+ import { hasUnseenActivity, type LiveSeenStore } from './live-seen';
58
+
59
+ /** The bell's whole state — see the file docblock for what each half means. */
60
+ export interface BellBadge {
61
+ /** What the badge shows. `0` renders no badge at all. */
62
+ count: number;
63
+ /**
64
+ * Whether any of it has arrived or moved since the reader last looked.
65
+ *
66
+ * The trigger paints this as its accent colour; a host with its own chrome
67
+ * decides how to say it, but it should be a difference somebody notices.
68
+ */
69
+ hasNew: boolean;
70
+ }
71
+
72
+ /**
73
+ * The badge for a host with no live activities: unread rows, and that is all.
74
+ *
75
+ * `hasNew` is `count > 0` here, and not as a simplification — an UNREAD row is
76
+ * one the reader has not seen, so for this host presence and novelty really are
77
+ * the same fact.
78
+ */
79
+ export function useInboxBellBadge(store: InboxStore, options: BadgeSyncOptions = {}): BellBadge {
80
+ // `useBadgeState` already blanks itself when disabled — the gate lives there,
81
+ // once, rather than at each of the three hooks that layer on it.
82
+ const { unread } = useBadgeState(store, options);
83
+ // MEMOISED, unlike the number `useUnreadCount` returns. `useSyncExternalStore`
84
+ // re-renders on every `patch` and `patch` always allocates, so a poll that
85
+ // comes back with an unchanged count would otherwise hand a host a new object
86
+ // every 60 s — enough to re-fire a `useEffect` keyed on it, or defeat a
87
+ // `React.memo` on the trigger, forever.
88
+ return useMemo(() => ({ count: unread, hasNew: unread > 0 }), [unread]);
89
+ }
90
+
91
+ /**
92
+ * The badge for a host that configured live activities.
93
+ *
94
+ * A SECOND hook rather than a flag on the one above, for the reason the bell
95
+ * itself is two components: `live.useActivities` is a hook, so a single hook
96
+ * reading an optional config would be calling one conditionally — which React
97
+ * reports as a crash somewhere else entirely. The factory knows statically
98
+ * which host it is building for and binds one.
99
+ *
100
+ * ## `enabled` is enforced HERE, not taken on trust
101
+ *
102
+ * A host is explicitly allowed to ignore the `active` hint and always answer —
103
+ * `./live-config` calls that "behaving correctly and merely paying for it" — so
104
+ * a signed-out header, which still MOUNTS the bell, can be handed a list of
105
+ * somebody's pedidos. The guard below is the only thing between that and a
106
+ * badge counting them.
107
+ *
108
+ * Defensive against the CONTRACT, not against an observed adopter: today's one
109
+ * honours the hint on every lever it has. That is exactly why the guard needs
110
+ * saying — nothing about the current tree would fail if it went, and the case
111
+ * that covers it has to build the ignoring host itself.
112
+ *
113
+ * ## What it costs the host, stated plainly
114
+ *
115
+ * The bell is mounted for as long as the app is, so unlike the panel's copy of
116
+ * this hook there is no "nobody is looking" state to stand down in — `active`
117
+ * is simply `enabled`. A host that answers by polling therefore polls for every
118
+ * signed-in reader whether or not they ever open the centre. That is the price
119
+ * of a badge that knows about live activities at all, and the reason to answer
120
+ * this hook from a pushed cache rather than from an interval.
121
+ */
122
+ export function useLiveBellBadge(
123
+ store: InboxStore,
124
+ live: LiveActivitiesConfig,
125
+ seen: LiveSeenStore,
126
+ options: BadgeSyncOptions = {},
127
+ ): BellBadge {
128
+ const enabled = options.enabled ?? true;
129
+ const { unread } = useBadgeState(store, options);
130
+ const activities = live.useActivities({ active: enabled });
131
+ const seenAt = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
132
+ // The store's own half is already blanked by `useBadgeState`; the `enabled`
133
+ // guard here is for the ACTIVITIES half, which comes from a host hook that
134
+ // may have ignored the hint.
135
+ //
136
+ // A live entry COUNTS. It is a notification — it is the one the reader most
137
+ // wants to know about — and the panel it opens lists it.
138
+ const count = enabled ? unread + activities.length : 0;
139
+ const hasNew = enabled && (unread > 0 || hasUnseenActivity(activities, seenAt));
140
+ // Memoised on the two RESULTS, not on `activities`. A host's hook returns a
141
+ // fresh array every render — the storefront's maps its query's rows, so
142
+ // structural sharing keeps the DATA identical and the array new — so an
143
+ // `activities` dependency would invalidate on every render and the memo would
144
+ // buy nothing at all. `hasUnseenActivity` runs unmemoised in front of it,
145
+ // which is a `.some()` over the handful of things happening at once.
146
+ return useMemo(() => ({ count, hasNew }), [count, hasNew]);
147
+ }
@@ -1,19 +1,26 @@
1
1
  /**
2
2
  * Bare bell trigger with the live unread badge — for hosts that do not already
3
- * have a styled icon-button slot. A host with its own trigger chrome uses
4
- * `useUnreadCount` + `Panel` directly.
3
+ * have a styled icon-button slot.
4
+ *
5
+ * A host with its own trigger chrome uses `useBellBadge` + `Panel` directly,
6
+ * and NOT `useUnreadCount`, which is what this sentence used to say. That
7
+ * advice was taken, verbatim and by name, by a storefront whose header needed
8
+ * its own trigger — and it gave that storefront a bell showing nothing at all
9
+ * while a live pedido sat in the panel it opens, because `useUnreadCount`
10
+ * counts inbox rows and knows nothing about what is happening right now.
5
11
  */
6
- import { useSyncExternalStore, type JSX } from 'react';
12
+ import type { JSX } from 'react';
7
13
 
8
14
  import { Badge } from '@12-apps/ui/data-display/Badge';
9
15
  import { Box } from '@12-apps/ui/mui/Box';
10
16
 
11
17
  import type { NotificationMessages } from '../messages';
12
18
 
19
+ import { useInboxBellBadge, useLiveBellBadge } from './bell-badge';
13
20
  import { BellIcon } from './bell-icon';
14
- import { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';
21
+ import type { NotificationsSignalHook, NotificationsSubscribe } from './hooks';
15
22
  import type { LiveActivitiesConfig } from './live-config';
16
- import { hasUnseenActivity, type LiveSeenStore } from './live-seen';
23
+ import type { LiveSeenStore } from './live-seen';
17
24
  import type { InboxStore } from './inbox-state';
18
25
 
19
26
  const triggerSx = {
@@ -111,14 +118,12 @@ export function BellButton({
111
118
  subscribe?: NotificationsSubscribe;
112
119
  useSignal?: NotificationsSignalHook;
113
120
  }): JSX.Element {
114
- const count = useUnreadCount(store, {
121
+ const badge = useInboxBellBadge(store, {
115
122
  enabled,
116
123
  ...(subscribe ? { subscribe } : {}),
117
124
  ...(useSignal ? { useSignal } : {}),
118
125
  });
119
- // No live config on this host: unread IS the whole count, and an unread row
120
- // is by definition something the reader has not seen.
121
- return <BellTrigger onClick={onClick} count={count} hasNew={count > 0} messages={messages} />;
126
+ return <BellTrigger onClick={onClick} {...badge} messages={messages} />;
122
127
  }
123
128
 
124
129
  /**
@@ -130,14 +135,9 @@ export function BellButton({
130
135
  * unrelated component rather than here. The factory knows statically which host
131
136
  * it is building for and picks one.
132
137
  *
133
- * ## What it costs the host, stated plainly
134
- *
135
- * The bell is mounted for as long as the app is, so unlike the panel's copy of
136
- * this hook there is no "nobody is looking" state to stand down in — `active`
137
- * is simply `enabled`. A host that answers by polling therefore polls for every
138
- * signed-in reader whether or not they ever open the centre. That is the price
139
- * of a badge that knows about live activities at all, and the reason to answer
140
- * this hook from a pushed cache rather than from an interval.
138
+ * What the number MEANS, and what it costs the host, is `bell-badge.ts` — the
139
+ * same hook a host with its own trigger chrome reaches through the factory's
140
+ * `useBellBadge`, so the two bells can never disagree about the count.
141
141
  */
142
142
  export function LiveBellButton({
143
143
  onClick,
@@ -156,22 +156,10 @@ export function LiveBellButton({
156
156
  live: LiveActivitiesConfig;
157
157
  seen: LiveSeenStore;
158
158
  }): JSX.Element {
159
- const unread = useUnreadCount(store, {
159
+ const badge = useLiveBellBadge(store, live, seen, {
160
160
  enabled,
161
161
  ...(subscribe ? { subscribe } : {}),
162
162
  ...(useSignal ? { useSignal } : {}),
163
163
  });
164
- const activities = live.useActivities({ active: enabled });
165
- const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
166
- const liveCount = enabled ? activities.length : 0;
167
- return (
168
- <BellTrigger
169
- onClick={onClick}
170
- // A live entry counts. It is a notification — it is the one the reader
171
- // most wants to know about — and the panel it opens lists it.
172
- count={unread + liveCount}
173
- hasNew={unread > 0 || (enabled && hasUnseenActivity(activities, seenIso))}
174
- messages={messages}
175
- />
176
- );
164
+ return <BellTrigger onClick={onClick} {...badge} messages={messages} />;
177
165
  }
@@ -3,6 +3,7 @@ import { useState, type ComponentType, type JSX } from 'react';
3
3
  import { messagesOf, type NotificationMessages } from '../messages';
4
4
 
5
5
  import { createNotificationsApiClient, type NotificationsApiClient } from './api';
6
+ import { useInboxBellBadge, useLiveBellBadge, type BellBadge } from './bell-badge';
6
7
  import { BellButton, LiveBellButton, type BellButtonProps } from './bell-button';
7
8
  import {
8
9
  useUnreadCount,
@@ -11,7 +12,7 @@ import {
11
12
  } from './hooks';
12
13
  import { createInboxStore, type InboxStore } from './inbox-state';
13
14
  import type { LiveActivitiesConfig } from './live-config';
14
- import { createLiveSeenStore } from './live-seen';
15
+ import { createLiveSeenStore, type LiveSeenStore } from './live-seen';
15
16
  import { lazyNotificationsPanel } from './panel-lazy';
16
17
  import type { NotificationsPanelProps } from './panel';
17
18
  import { lazyPreferencesPage } from './page-lazy';
@@ -94,8 +95,31 @@ export interface WebNotifications {
94
95
  enabled?: boolean;
95
96
  onNavigate?: (link: string) => void;
96
97
  }>;
97
- /** The badge number, for a host with its own trigger chrome. */
98
+ /**
99
+ * The unread INBOX count.
100
+ *
101
+ * For a host with its own trigger chrome only when that host configured no
102
+ * live activities — otherwise it is a bell that ignores everything happening
103
+ * right now, and `useBellBadge` below is the door. Still the right hook for
104
+ * anything that genuinely wants "how many unread rows".
105
+ */
98
106
  useUnreadCount: (options?: { enabled?: boolean }) => number;
107
+ /**
108
+ * The badge's NUMBER AND TONE, for a host with its own trigger chrome.
109
+ *
110
+ * What `useUnreadCount` should have been for a host that also configured live
111
+ * activities, and the reason it is a second door rather than a change to that
112
+ * one: a count alone cannot express a bell, because a live entry is present
113
+ * without being news (see `./bell-badge`). A host that renders
114
+ * `useUnreadCount` in its own chrome gets a badge that ignores everything
115
+ * happening right now — which is not a subtle wrongness, it is the pinned
116
+ * pedido on screen going uncounted.
117
+ *
118
+ * Identical to what this package's own `BellButton` draws, because it is the
119
+ * hook that bell uses. Without live activities configured it is
120
+ * `useUnreadCount` plus `hasNew: count > 0`.
121
+ */
122
+ useBellBadge: (options?: { enabled?: boolean }) => BellBadge;
99
123
  /** The shared client state, for host glue. */
100
124
  store: InboxStore;
101
125
  /** The bound wire client. */
@@ -104,6 +128,33 @@ export interface WebNotifications {
104
128
  messages: NotificationMessages;
105
129
  }
106
130
 
131
+ /** What the factory passes both badge hooks: whatever realtime wiring it has. */
132
+ type SubscribeOption = {
133
+ subscribe?: NotificationsSubscribe;
134
+ useSignal?: NotificationsSignalHook;
135
+ };
136
+
137
+ /**
138
+ * The two badge hooks, bound to this factory's store.
139
+ *
140
+ * `useBellBadge` is chosen ONCE here, the same way `Bell` is below and for the
141
+ * same reason: `live.useActivities` is a hook, so which implementation runs
142
+ * must not be a per-render decision.
143
+ */
144
+ function bindBadgeHooks(
145
+ store: InboxStore,
146
+ subscribeOption: SubscribeOption,
147
+ liveSeen: LiveSeenStore,
148
+ live: LiveActivitiesConfig | undefined,
149
+ ): Pick<WebNotifications, 'useUnreadCount' | 'useBellBadge'> {
150
+ return {
151
+ useUnreadCount: (options = {}) => useUnreadCount(store, { ...options, ...subscribeOption }),
152
+ useBellBadge: live
153
+ ? (options = {}) => useLiveBellBadge(store, live, liveSeen, { ...options, ...subscribeOption })
154
+ : (options = {}) => useInboxBellBadge(store, { ...options, ...subscribeOption }),
155
+ };
156
+ }
157
+
107
158
  export function createWebNotifications(config: NotificationsWebConfig): WebNotifications {
108
159
  const messages = messagesOf(config);
109
160
  const api = createNotificationsApiClient(
@@ -147,9 +198,7 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
147
198
  ...(live ? { live, liveSeen } : {}),
148
199
  });
149
200
 
150
- function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
151
- return useUnreadCount(store, { ...options, ...subscribeOption });
152
- }
201
+ const badgeHooks = bindBadgeHooks(store, subscribeOption, liveSeen, live);
153
202
 
154
203
  function BellWithPanel({
155
204
  enabled = true,
@@ -176,7 +225,7 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
176
225
  BellButton: Bell,
177
226
  Panel,
178
227
  BellWithPanel,
179
- useUnreadCount: useBoundUnreadCount,
228
+ ...badgeHooks,
180
229
  store,
181
230
  api,
182
231
  messages,
@@ -48,23 +48,60 @@ export function useInboxState(store: InboxStore): InboxState {
48
48
  return useSyncExternalStore(store.subscribe, store.getState, store.getState);
49
49
  }
50
50
 
51
+ /** What both badge hooks below take, and what the bell passes them. */
52
+ export interface BadgeSyncOptions {
53
+ enabled?: boolean;
54
+ subscribe?: NotificationsSubscribe;
55
+ useSignal?: NotificationsSignalHook;
56
+ }
57
+
58
+ /**
59
+ * What a disabled badge reads, instead of whatever the store happens to hold.
60
+ *
61
+ * A CONSTANT, so `useSyncExternalStore`'s identity comparison sees no change
62
+ * across the renders of a signed-out session.
63
+ */
64
+ const NOTHING_TO_SHOW: InboxState = {
65
+ unread: 0,
66
+ items: [],
67
+ status: 'idle',
68
+ nextCursor: null,
69
+ loadingMore: false,
70
+ };
71
+
51
72
  /**
52
- * The bell badge number: pushed while a subscription is live, polled otherwise.
73
+ * The badge's server state, kept fresh: pushed while a subscription is live,
74
+ * polled otherwise.
75
+ *
76
+ * The whole state rather than the count, because every badge hook that layers
77
+ * on top of it needs the poll and the subscription mounted exactly ONCE per
78
+ * bell — read through two hooks, a bell that showed both a number and a tone
79
+ * would open two of everything.
80
+ *
81
+ * ## `enabled` gates the ANSWER, not only the fetching
53
82
  *
54
- * `enabled` gates the poll AND the subscription. A signed-out header still
55
- * mounts the bell, and there is nothing for it to hear.
83
+ * It gates the poll and the subscription, which is the obvious half. It also
84
+ * blanks the returned state, which is the half that was missing and matters
85
+ * more: the store is per FACTORY and a host builds one at module scope for the
86
+ * whole app, so signing out does not empty it — `refreshBadge` swallows the 401
87
+ * and leaves the last number in place. Without this, a hook told there is
88
+ * nobody signed in hands back the PREVIOUS reader's unread count and their
89
+ * inbox rows.
90
+ *
91
+ * Deliberately here rather than at each caller. It was at each caller, three
92
+ * times, in three shapes, and two of them were dead weight no test could reach
93
+ * — which is what an invariant looks like just before one copy of it goes
94
+ * missing.
95
+ *
96
+ * INTERNAL. Not exported from `./index`: it hands back rows as well as a count,
97
+ * and a host wanting a number has `useUnreadCount` or the factory's
98
+ * `useBellBadge`.
56
99
  */
57
- export function useUnreadCount(
58
- store: InboxStore,
59
- options: {
60
- enabled?: boolean;
61
- subscribe?: NotificationsSubscribe;
62
- useSignal?: NotificationsSignalHook;
63
- } = {},
64
- ): number {
100
+ export function useBadgeState(store: InboxStore, options: BadgeSyncOptions = {}): InboxState {
65
101
  const enabled = options.enabled ?? true;
66
102
  const subscribe = options.subscribe;
67
- const { unread } = useInboxState(store);
103
+ const live = useInboxState(store);
104
+ const state = enabled ? live : NOTHING_TO_SHOW;
68
105
 
69
106
  // Called unconditionally — it is a hook, so it cannot sit behind `enabled`.
70
107
  // The host's own hook decides what to do when there is nothing to hear.
@@ -91,7 +128,18 @@ export function useUnreadCount(
91
128
  };
92
129
  }, [store, enabled, subscribe]);
93
130
 
94
- return enabled ? unread : 0;
131
+ return state;
132
+ }
133
+
134
+ /**
135
+ * The bell badge number, for a host with its own trigger chrome.
136
+ *
137
+ * A host that also publishes live activities wants `useBellBadge` from the
138
+ * factory instead — this one counts inbox rows and knows nothing about what is
139
+ * happening right now.
140
+ */
141
+ export function useUnreadCount(store: InboxStore, options: BadgeSyncOptions = {}): number {
142
+ return useBadgeState(store, options).unread;
95
143
  }
96
144
 
97
145
  /** The panel's list — only fetches while the panel is open. */
@@ -26,6 +26,15 @@ export {
26
26
  type PushRegistrationPayload,
27
27
  } from './api';
28
28
 
29
+ // `useInboxBellBadge` and `useLiveBellBadge` are deliberately NOT exported, and
30
+ // the live one is why: it takes a `LiveSeenStore`, and the only store that
31
+ // works is the factory's own — the panel writes "seen" into THAT one. A host
32
+ // handed the hook and no way to build the store would either hand-roll a
33
+ // `{read, mark, subscribe}` nothing ever writes to, and get a badge that is
34
+ // permanently `new`, or reach for `createLiveSeenStore` and find it unexported
35
+ // too. `useBellBadge` off the factory is the door, already bound to both.
36
+ export type { BellBadge } from './bell-badge';
37
+
29
38
  export {
30
39
  BADGE_POLL_MS,
31
40
  BADGE_RECONCILE_MS,
@@ -36,10 +45,14 @@ export {
36
45
  type InboxStore,
37
46
  } from './inbox-state';
38
47
 
48
+ // `useBadgeState` is NOT here, for the reason the raw badge hooks above are
49
+ // not: it hands back the inbox ROWS as well as the count, and a host wanting a
50
+ // number already has `useUnreadCount` and the factory's `useBellBadge`.
39
51
  export {
40
52
  useInboxList,
41
53
  useInboxState,
42
54
  useUnreadCount,
55
+ type BadgeSyncOptions,
43
56
  type NotificationsSignalHook,
44
57
  type NotificationsSubscribe,
45
58
  } from './hooks';
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  DEFAULT_CHANNEL_ROW,
3
- enabledChannelsOf,
4
- mergeChoices,
3
+ explicitChoicesOf,
5
4
  mergeStoredRow,
5
+ resolveTypeChannels,
6
6
  type ChannelMatrix,
7
7
  type ChannelRow,
8
+ type TypeChannelRules,
8
9
  } from '../preferences-core';
9
10
  import type {
10
11
  NotificationCategory,
@@ -32,10 +33,20 @@ export interface NotificationPreferenceStore {
32
33
  userId: string,
33
34
  input: Partial<Record<NotificationCategory, Partial<ChannelRow>>>,
34
35
  ): Promise<void>;
35
- /** The channels enabled for one (user, category) — the router's gate. */
36
+ /**
37
+ * The channels enabled for one (user, category) — the router's gate.
38
+ *
39
+ * `rules` carries the emitting TYPE's own declarations (availability and
40
+ * per-type defaults). It is OPTIONAL, and that is what keeps a host store
41
+ * written before it working: a two-parameter implementation is assignable to
42
+ * this signature unchanged, and omitting the argument asks the same question
43
+ * the store has always answered. The router does not rely on a store
44
+ * honouring it — it caps the result by availability itself.
45
+ */
36
46
  enabledChannels(
37
47
  userId: string,
38
48
  category: NotificationCategory,
49
+ rules?: TypeChannelRules,
39
50
  ): Promise<NotificationChannel[]>;
40
51
  }
41
52
 
@@ -72,6 +83,14 @@ export function createPreferenceStore(
72
83
  * A category outside the taxonomy is IGNORED rather than stored: the DB
73
84
  * CHECK would reject it anyway, and a 500 from a stale client's extra key
74
85
  * would fail the whole save including the toggle the user did flip.
86
+ *
87
+ * What is written is the user's EXPLICIT choices only, never their
88
+ * effective row. Merging onto the effective row wrote a boolean for all
89
+ * four channels the moment anyone touched any switch, so the row could
90
+ * never again say "no opinion" about a channel — which silently disabled
91
+ * every per-type and per-host default for that user, and defeated the
92
+ * missing-key fallback that lets a new channel ship without a data
93
+ * migration.
75
94
  */
76
95
  async save(userId, input) {
77
96
  const client = await db();
@@ -80,8 +99,7 @@ export function createPreferenceStore(
80
99
  const existing = await client.notificationPreference.findUnique({
81
100
  where: { userId_category: { userId, category } },
82
101
  });
83
- const current = existing ? mergeStoredRow(existing.channels, defaultRow) : defaultRow;
84
- const channels = mergeChoices(current, choices);
102
+ const channels = { ...explicitChoicesOf(existing?.channels), ...choices };
85
103
  await client.notificationPreference.upsert({
86
104
  where: { userId_category: { userId, category } },
87
105
  create: { userId, category, channels },
@@ -90,14 +108,16 @@ export function createPreferenceStore(
90
108
  }
91
109
  },
92
110
 
93
- async enabledChannels(userId, category) {
111
+ async enabledChannels(userId, category, rules) {
94
112
  const client = await db();
95
113
  const row = await client.notificationPreference.findUnique({
96
114
  where: { userId_category: { userId, category } },
97
115
  });
98
- return enabledChannelsOf(
99
- row ? mergeStoredRow(row.channels, defaultRow) : defaultRow,
100
- );
116
+ return resolveTypeChannels({
117
+ stored: row?.channels,
118
+ categoryDefaults: defaultRow,
119
+ rules,
120
+ });
101
121
  },
102
122
  };
103
123
  }