@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/src/live.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE ACTIVITIES — the notification centre's second kind of entry.
|
|
3
|
+
*
|
|
4
|
+
* An inbox notification is an EVENT: it happened, it is stamped, it is read or
|
|
5
|
+
* unread, and it is still there tomorrow saying the same thing. A live activity
|
|
6
|
+
* is ONGOING STATE: it is pinned above the list, it has no read/unread, nobody
|
|
7
|
+
* deletes it, it updates itself, and it is GONE the moment the thing it tracks
|
|
8
|
+
* finishes.
|
|
9
|
+
*
|
|
10
|
+
* The distinction is not cosmetic. A row that says "your order is on its way",
|
|
11
|
+
* read an hour later, is a claim about the past presented as news — and the
|
|
12
|
+
* more reliable the inbox is, the more of those a person accumulates. Somewhere
|
|
13
|
+
* in that list is the one question they actually have, which is *where is it
|
|
14
|
+
* now*. A live activity answers that question and then removes itself, which is
|
|
15
|
+
* the property no amount of better event copy can buy.
|
|
16
|
+
*
|
|
17
|
+
* ## This file is domain-free, and that is the whole design
|
|
18
|
+
*
|
|
19
|
+
* Nothing here knows what is being tracked. A host publishes activities through
|
|
20
|
+
* `LiveActivitiesConfig.useActivities` (see `./react/live-config`) and this
|
|
21
|
+
* package owns the contract, the placement, the rendering and the OS-level
|
|
22
|
+
* collapse. The package that raises an alert has never been this package's
|
|
23
|
+
* business — see the `generators` seam — and the thing an alert is ABOUT is not
|
|
24
|
+
* either.
|
|
25
|
+
*
|
|
26
|
+
* ## The lane
|
|
27
|
+
*
|
|
28
|
+
* `steps` + `activeStepId` describe a progress lane, because "how far along is
|
|
29
|
+
* this" is the shape almost every ongoing subject has. Both are optional in
|
|
30
|
+
* effect: an activity with no lane (`steps: []`, `activeStepId: null`) is a
|
|
31
|
+
* perfectly good live entry — a heading, a sentence and a timestamp that keeps
|
|
32
|
+
* moving.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** One stop on a live activity's lane. */
|
|
36
|
+
export interface LiveActivityStep {
|
|
37
|
+
/** Stable across updates — the id is what `activeStepId` names. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** What the reader sees under the dot. The HOST's word, in the reader's language. */
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One thing that is happening right now, as the notification centre shows it. */
|
|
44
|
+
export interface LiveActivity {
|
|
45
|
+
/**
|
|
46
|
+
* Stable for the whole life of the subject.
|
|
47
|
+
*
|
|
48
|
+
* The SAME id on every update, because it is the identity of the thing being
|
|
49
|
+
* tracked and not of the message: the panel keys on it so a stage change
|
|
50
|
+
* re-renders one card rather than swapping two, and it is what
|
|
51
|
+
* {@link livePushTag} collapses an OS notification onto.
|
|
52
|
+
*
|
|
53
|
+
* **Unique among the activities live at one moment**, for the same reason:
|
|
54
|
+
* it is the React key and the card's test id. Two activities sharing one
|
|
55
|
+
* gives a duplicate-key warning and a card that silently shows the wrong
|
|
56
|
+
* subject.
|
|
57
|
+
*/
|
|
58
|
+
id: string;
|
|
59
|
+
/**
|
|
60
|
+
* Which host concern this belongs to, e.g. `order`, `delivery`, `import`.
|
|
61
|
+
*
|
|
62
|
+
* Free-form and never rendered — it exists so a host publishing from two
|
|
63
|
+
* sources can tell its own activities apart in a test or a log without
|
|
64
|
+
* parsing `id`.
|
|
65
|
+
*/
|
|
66
|
+
kind: string;
|
|
67
|
+
/** The heading — what is happening. */
|
|
68
|
+
title: string;
|
|
69
|
+
/** The line under it: the detail that makes the heading actionable, or none. */
|
|
70
|
+
body: string | null;
|
|
71
|
+
/** Where tapping the card goes, as a same-origin path. `null` renders no link. */
|
|
72
|
+
link: string | null;
|
|
73
|
+
/** The lane, in the order it is walked. Empty when this subject has no lane. */
|
|
74
|
+
steps: readonly LiveActivityStep[];
|
|
75
|
+
/**
|
|
76
|
+
* The stop the subject is AT — one of `steps`, or `null` for a laneless entry.
|
|
77
|
+
*
|
|
78
|
+
* An id that names no step is neither, and it is handled rather than trusted:
|
|
79
|
+
* see {@link liveActivityLane}.
|
|
80
|
+
*/
|
|
81
|
+
activeStepId: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* ISO-8601 — when the subject last MOVED, not when it was last polled.
|
|
84
|
+
*
|
|
85
|
+
* A timestamp that advances on every read would render "just now" forever,
|
|
86
|
+
* which is the one thing a live entry must not say when nothing is happening.
|
|
87
|
+
*/
|
|
88
|
+
updatedAt: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A live activity's lane, resolved for rendering. */
|
|
92
|
+
export interface LiveActivityLane {
|
|
93
|
+
steps: readonly LiveActivityStep[];
|
|
94
|
+
activeStepId: string;
|
|
95
|
+
/** Every stop BEFORE the active one — never the active one itself. */
|
|
96
|
+
completed: ReadonlySet<string>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The lane to draw, or `null` when this activity has none to draw.
|
|
101
|
+
*
|
|
102
|
+
* Three cases collapse to `null`, and the third is the one worth writing down:
|
|
103
|
+
* no steps, no active step, and **an active step the lane does not contain**.
|
|
104
|
+
* That last one is not a theoretical defect — it is what a host produces the
|
|
105
|
+
* first time a subject reaches a stage the lane was filtered to exclude, and
|
|
106
|
+
* the symptom is a row of dots with NONE of them lit, which reads as a process
|
|
107
|
+
* that has stopped. A card with no lane still says what is happening and when
|
|
108
|
+
* it last moved; a dead lane says the tracking is broken.
|
|
109
|
+
*
|
|
110
|
+
* `completed` deliberately stops short of the active stop: a completed step
|
|
111
|
+
* draws a tick, and ticking the stop the subject is sitting in claims it has
|
|
112
|
+
* already left.
|
|
113
|
+
*/
|
|
114
|
+
export function liveActivityLane(activity: LiveActivity): LiveActivityLane | null {
|
|
115
|
+
const activeStepId = activity.activeStepId;
|
|
116
|
+
if (activeStepId === null || activity.steps.length === 0) return null;
|
|
117
|
+
const reached = activity.steps.findIndex((step) => step.id === activeStepId);
|
|
118
|
+
if (reached < 0) return null;
|
|
119
|
+
return {
|
|
120
|
+
steps: activity.steps,
|
|
121
|
+
activeStepId,
|
|
122
|
+
completed: new Set(activity.steps.slice(0, reached).map((step) => step.id)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The reserved `data` key that ties a NOTIFICATION to a live subject.
|
|
128
|
+
*
|
|
129
|
+
* A notification and a live activity are different objects with different
|
|
130
|
+
* lifetimes, and this is the only thing that joins them: a generator whose
|
|
131
|
+
* event is about something also tracked live puts the activity's id here, and
|
|
132
|
+
* every channel that can collapse gets to.
|
|
133
|
+
*
|
|
134
|
+
* data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
|
|
135
|
+
*
|
|
136
|
+
* A plain string on purpose. An object here would grow a second wire contract
|
|
137
|
+
* inside a column this package stores verbatim.
|
|
138
|
+
*/
|
|
139
|
+
export const LIVE_SUBJECT_KEY = 'liveSubject';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Namespaces the tray tag, so a live tag does not collide with the ones a host
|
|
143
|
+
* already uses.
|
|
144
|
+
*
|
|
145
|
+
* A convention rather than an enforcement — nothing stops a host emitting its
|
|
146
|
+
* own `live:`-prefixed tags — but it means the two id spaces have to be made to
|
|
147
|
+
* meet rather than meeting by accident.
|
|
148
|
+
*/
|
|
149
|
+
export const LIVE_PUSH_TAG_PREFIX = 'live:';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The OS notification tag for a push about a live subject, or `null` for an
|
|
153
|
+
* ordinary event.
|
|
154
|
+
*
|
|
155
|
+
* ## What the tag buys, and why it is the PWA half of this feature
|
|
156
|
+
*
|
|
157
|
+
* A `tag` makes a new notification REPLACE the one already in the tray instead
|
|
158
|
+
* of stacking under it, and — unless the sender asks otherwise — replace it
|
|
159
|
+
* *silently*. So a subject that moves through four stages costs one tray entry
|
|
160
|
+
* and one buzz, and the entry that remains is the CURRENT one. Without it a
|
|
161
|
+
* phone accumulates one alert per stage, all of them still asserting a stage
|
|
162
|
+
* the subject has since left; the freshest is at the top and the reader has to
|
|
163
|
+
* work out that the three below it are history.
|
|
164
|
+
*
|
|
165
|
+
* That is as close as the web platform gets to an ongoing/live notification,
|
|
166
|
+
* and it is the half that reaches a person who does not have the app open —
|
|
167
|
+
* which is most of the time a live activity is live.
|
|
168
|
+
*
|
|
169
|
+
* The id is passed through unchanged: it is the host's, it is already unique
|
|
170
|
+
* per subject, and a hash would make the tray impossible to reason about from a
|
|
171
|
+
* log line.
|
|
172
|
+
*/
|
|
173
|
+
export function livePushTag(
|
|
174
|
+
data: Readonly<Record<string, unknown>> | null | undefined,
|
|
175
|
+
): string | null {
|
|
176
|
+
const subject = data?.[LIVE_SUBJECT_KEY];
|
|
177
|
+
if (typeof subject !== 'string' || subject === '') return null;
|
|
178
|
+
return `${LIVE_PUSH_TAG_PREFIX}${subject}`;
|
|
179
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* have a styled icon-button slot. A host with its own trigger chrome uses
|
|
4
4
|
* `useUnreadCount` + `Panel` directly.
|
|
5
5
|
*/
|
|
6
|
-
import type
|
|
6
|
+
import { useSyncExternalStore, type JSX } from 'react';
|
|
7
7
|
|
|
8
8
|
import { Badge } from '@12-apps/ui/data-display/Badge';
|
|
9
9
|
import { Box } from '@12-apps/ui/mui/Box';
|
|
@@ -12,6 +12,8 @@ import type { NotificationMessages } from '../messages';
|
|
|
12
12
|
|
|
13
13
|
import { BellIcon } from './bell-icon';
|
|
14
14
|
import { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';
|
|
15
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
16
|
+
import { hasUnseenActivity, type LiveSeenStore } from './live-seen';
|
|
15
17
|
import type { InboxStore } from './inbox-state';
|
|
16
18
|
|
|
17
19
|
const triggerSx = {
|
|
@@ -40,42 +42,136 @@ export interface BellButtonProps {
|
|
|
40
42
|
enabled?: boolean;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The trigger itself, given a count and whether any of it is NEW.
|
|
47
|
+
*
|
|
48
|
+
* Presentational, and shared by both bells below, so the two can never drift on
|
|
49
|
+
* what the badge looks like — only on where the number comes from.
|
|
50
|
+
*
|
|
51
|
+
* ## The two tones
|
|
52
|
+
*
|
|
53
|
+
* `primary` says *something happened*; `neutral` says *something is present*. A
|
|
54
|
+
* live activity is the reason that distinction has to exist: it stays on the
|
|
55
|
+
* panel for as long as the thing is happening, so a bell that painted every
|
|
56
|
+
* live entry as new would be permanently red for a pedido the reader already
|
|
57
|
+
* looked at, and a bell that ignored them would say nothing at all while one
|
|
58
|
+
* was running. Grey keeps the count honest without spending attention twice.
|
|
59
|
+
*/
|
|
60
|
+
function BellTrigger({
|
|
44
61
|
onClick,
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
count,
|
|
63
|
+
hasNew,
|
|
47
64
|
messages,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
}: {
|
|
66
|
+
onClick: () => void;
|
|
67
|
+
count: number;
|
|
68
|
+
hasNew: boolean;
|
|
52
69
|
messages: NotificationMessages;
|
|
53
|
-
subscribe?: NotificationsSubscribe;
|
|
54
|
-
useSignal?: NotificationsSignalHook;
|
|
55
70
|
}): JSX.Element {
|
|
56
|
-
const count = useUnreadCount(store, {
|
|
57
|
-
enabled,
|
|
58
|
-
...(subscribe ? { subscribe } : {}),
|
|
59
|
-
...(useSignal ? { useSignal } : {}),
|
|
60
|
-
});
|
|
61
71
|
return (
|
|
62
72
|
<Box
|
|
63
73
|
component="button"
|
|
64
74
|
type="button"
|
|
65
75
|
onClick={onClick}
|
|
76
|
+
// `openBellWithUnread` rather than a new message, and not for want of
|
|
77
|
+
// precision: `NotificationMessages` is REQUIRED of every host, so adding
|
|
78
|
+
// a field is a breaking change to a package several apps already mount.
|
|
79
|
+
// The sentence a host wrote for "you have N" is the sentence this wants.
|
|
66
80
|
aria-label={count > 0 ? messages.openBellWithUnread(count) : messages.openBell}
|
|
67
81
|
data-testid="notifications-bell"
|
|
68
82
|
sx={triggerSx}
|
|
69
83
|
>
|
|
70
84
|
<Badge
|
|
71
85
|
content={count > 0 ? count : undefined}
|
|
72
|
-
color=
|
|
86
|
+
color={hasNew ? 'primary' : 'neutral'}
|
|
73
87
|
variant="count"
|
|
74
88
|
max={99}
|
|
75
89
|
data-testid="notifications-badge"
|
|
90
|
+
// The tone is carried by a colour, and a colour is not something a
|
|
91
|
+
// test can read — nor, on its own, a signal every reader can. This is
|
|
92
|
+
// what the tests assert on.
|
|
93
|
+
data-tone={hasNew ? 'new' : 'seen'}
|
|
76
94
|
>
|
|
77
95
|
<BellIcon size={28} />
|
|
78
96
|
</Badge>
|
|
79
97
|
</Box>
|
|
80
98
|
);
|
|
81
99
|
}
|
|
100
|
+
|
|
101
|
+
export function BellButton({
|
|
102
|
+
onClick,
|
|
103
|
+
enabled = true,
|
|
104
|
+
store,
|
|
105
|
+
messages,
|
|
106
|
+
subscribe,
|
|
107
|
+
useSignal,
|
|
108
|
+
}: BellButtonProps & {
|
|
109
|
+
store: InboxStore;
|
|
110
|
+
messages: NotificationMessages;
|
|
111
|
+
subscribe?: NotificationsSubscribe;
|
|
112
|
+
useSignal?: NotificationsSignalHook;
|
|
113
|
+
}): JSX.Element {
|
|
114
|
+
const count = useUnreadCount(store, {
|
|
115
|
+
enabled,
|
|
116
|
+
...(subscribe ? { subscribe } : {}),
|
|
117
|
+
...(useSignal ? { useSignal } : {}),
|
|
118
|
+
});
|
|
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} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The bell for a host that configured live activities.
|
|
126
|
+
*
|
|
127
|
+
* A SECOND component rather than a flag on the one above, because the host's
|
|
128
|
+
* `useActivities` is a hook: reading an optional config inside one component
|
|
129
|
+
* would mean calling it conditionally, which React reports as a crash in some
|
|
130
|
+
* unrelated component rather than here. The factory knows statically which host
|
|
131
|
+
* it is building for and picks one.
|
|
132
|
+
*
|
|
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.
|
|
141
|
+
*/
|
|
142
|
+
export function LiveBellButton({
|
|
143
|
+
onClick,
|
|
144
|
+
enabled = true,
|
|
145
|
+
store,
|
|
146
|
+
messages,
|
|
147
|
+
subscribe,
|
|
148
|
+
useSignal,
|
|
149
|
+
live,
|
|
150
|
+
seen,
|
|
151
|
+
}: BellButtonProps & {
|
|
152
|
+
store: InboxStore;
|
|
153
|
+
messages: NotificationMessages;
|
|
154
|
+
subscribe?: NotificationsSubscribe;
|
|
155
|
+
useSignal?: NotificationsSignalHook;
|
|
156
|
+
live: LiveActivitiesConfig;
|
|
157
|
+
seen: LiveSeenStore;
|
|
158
|
+
}): JSX.Element {
|
|
159
|
+
const unread = useUnreadCount(store, {
|
|
160
|
+
enabled,
|
|
161
|
+
...(subscribe ? { subscribe } : {}),
|
|
162
|
+
...(useSignal ? { useSignal } : {}),
|
|
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
|
+
);
|
|
177
|
+
}
|
|
@@ -3,13 +3,15 @@ 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 { BellButton, type BellButtonProps } from './bell-button';
|
|
6
|
+
import { BellButton, LiveBellButton, type BellButtonProps } from './bell-button';
|
|
7
7
|
import {
|
|
8
8
|
useUnreadCount,
|
|
9
9
|
type NotificationsSignalHook,
|
|
10
10
|
type NotificationsSubscribe,
|
|
11
11
|
} from './hooks';
|
|
12
12
|
import { createInboxStore, type InboxStore } from './inbox-state';
|
|
13
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
14
|
+
import { createLiveSeenStore } from './live-seen';
|
|
13
15
|
import { lazyNotificationsPanel } from './panel-lazy';
|
|
14
16
|
import type { NotificationsPanelProps } from './panel';
|
|
15
17
|
import { lazyPreferencesPage } from './page-lazy';
|
|
@@ -55,6 +57,15 @@ export interface NotificationsWebConfig {
|
|
|
55
57
|
useSignal?: NotificationsSignalHook;
|
|
56
58
|
/** The browser push enable step's host seams (SW path, platform hint). */
|
|
57
59
|
webPush?: WebPushSetupConfig;
|
|
60
|
+
/**
|
|
61
|
+
* LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.
|
|
62
|
+
*
|
|
63
|
+
* Opt-in, and absent means absent: a host that passes nothing gets the panel
|
|
64
|
+
* it had, with no section, no heading and no reserved space. See
|
|
65
|
+
* `./live-config` for the two things a host has to supply (where they come
|
|
66
|
+
* from, and what the section says) and `../live` for what one IS.
|
|
67
|
+
*/
|
|
68
|
+
liveActivities?: LiveActivitiesConfig;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
export interface WebNotifications {
|
|
@@ -107,10 +118,34 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
|
|
|
107
118
|
...(config.useSignal ? { useSignal: config.useSignal } : {}),
|
|
108
119
|
};
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
121
|
+
// One store per factory, shared by the bell that READS it and the panel that
|
|
122
|
+
// WRITES it — the same arrangement as the inbox store above, and for the same
|
|
123
|
+
// reason: two independent copies would disagree about what the reader saw.
|
|
124
|
+
const liveSeen = createLiveSeenStore();
|
|
125
|
+
|
|
126
|
+
// Chosen ONCE, here, because `useActivities` is a hook and the choice must
|
|
127
|
+
// not be made per render: a bell that read an optional config inside itself
|
|
128
|
+
// would be calling a hook conditionally.
|
|
129
|
+
const live = config.liveActivities;
|
|
130
|
+
const Bell: ComponentType<BellButtonProps> = live
|
|
131
|
+
? (props) => (
|
|
132
|
+
<LiveBellButton
|
|
133
|
+
{...props}
|
|
134
|
+
store={store}
|
|
135
|
+
messages={messages}
|
|
136
|
+
live={live}
|
|
137
|
+
seen={liveSeen}
|
|
138
|
+
{...subscribeOption}
|
|
139
|
+
/>
|
|
140
|
+
)
|
|
141
|
+
: (props) => (
|
|
142
|
+
<BellButton {...props} store={store} messages={messages} {...subscribeOption} />
|
|
143
|
+
);
|
|
144
|
+
const Panel = lazyNotificationsPanel({
|
|
145
|
+
store,
|
|
146
|
+
messages,
|
|
147
|
+
...(live ? { live, liveSeen } : {}),
|
|
148
|
+
});
|
|
114
149
|
|
|
115
150
|
function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
|
|
116
151
|
return useUnreadCount(store, { ...options, ...subscribeOption });
|
package/src/react/index.ts
CHANGED
|
@@ -44,6 +44,35 @@ export {
|
|
|
44
44
|
type NotificationsSubscribe,
|
|
45
45
|
} from './hooks';
|
|
46
46
|
|
|
47
|
+
// `LiveActivityCard` is deliberately NOT exported. It takes the clock as a
|
|
48
|
+
// prop, and the minute tick that produces one lives in `LiveSection` — so an
|
|
49
|
+
// external composer would either reimplement the tick or pass `Date.now()` once
|
|
50
|
+
// and get the frozen timestamp `relative-time.ts` was changed to prevent.
|
|
51
|
+
// `LiveSection` is the composable unit and carries its own clock.
|
|
52
|
+
export {
|
|
53
|
+
type LiveActivitiesConfig,
|
|
54
|
+
type LiveActivitiesHook,
|
|
55
|
+
type LiveActivityMessages,
|
|
56
|
+
} from './live-config';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The contract itself, re-exported from the entry a host WIRES through.
|
|
60
|
+
*
|
|
61
|
+
* It lives in the root, because both halves need it and neither owns it — but a
|
|
62
|
+
* host writing `useActivities` imports the seam from `./react` and then has to
|
|
63
|
+
* reach into a second entry for the one type that seam returns. Measured on the
|
|
64
|
+
* first adopter: a `LiveActivity` imported from here failed to compile with
|
|
65
|
+
* "declares 'LiveActivity' locally, but it is not exported", which is a
|
|
66
|
+
* confusing way to say "you wanted the other subpath".
|
|
67
|
+
*/
|
|
68
|
+
export {
|
|
69
|
+
liveActivityLane,
|
|
70
|
+
type LiveActivity,
|
|
71
|
+
type LiveActivityLane,
|
|
72
|
+
type LiveActivityStep,
|
|
73
|
+
} from '../live';
|
|
74
|
+
export { LiveSection, type LiveSectionProps } from './live-section';
|
|
75
|
+
|
|
47
76
|
export { relativeTime } from './relative-time';
|
|
48
77
|
|
|
49
78
|
export {
|