@12-apps/notifications 4.8.0 → 4.10.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 +173 -1
- package/README.md +138 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
- package/dist/chunk-5Y7QRORV.js.map +1 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/chunk-EKUSNUBT.js +222 -0
- package/dist/chunk-EKUSNUBT.js.map +1 -0
- package/dist/chunk-FTFLU6KH.js +114 -0
- package/dist/chunk-FTFLU6KH.js.map +1 -0
- package/dist/chunk-JCVRQ42B.js +226 -0
- package/dist/chunk-JCVRQ42B.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-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
- package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
- package/dist/email/index.d.ts +26 -0
- package/dist/email/index.js +20 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/locales.d.ts +43 -0
- package/dist/email/locales.js +29 -0
- package/dist/email/locales.js.map +1 -0
- package/dist/email/previews/hono.d.ts +46 -0
- package/dist/email/previews/hono.js +33 -0
- package/dist/email/previews/hono.js.map +1 -0
- package/dist/email/previews/index.d.ts +67 -0
- package/dist/email/previews/index.js +12 -0
- package/dist/email/previews/index.js.map +1 -0
- package/dist/email/previews/react/index.d.ts +43 -0
- package/dist/email/previews/react/index.js +58 -0
- package/dist/email/previews/react/index.js.map +1 -0
- package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +5 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +15 -5
- package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/index.d.ts +39 -1
- package/dist/manifest/index.js +9 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +32 -7
- package/dist/manifest/server.js +16 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +19 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
- package/dist/panel-T36JEMO3.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +55 -6
- package/dist/react/index.js +16 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +6 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +22 -2
- package/src/email/chrome.en-US.ts +8 -0
- package/src/email/chrome.pt-BR.ts +21 -0
- package/src/email/index.ts +43 -0
- package/src/email/locales.ts +29 -0
- package/src/email/previews/catalog.ts +208 -0
- package/src/email/previews/hono.ts +69 -0
- package/src/email/previews/index.ts +26 -0
- package/src/email/previews/react/copy.en-US.ts +24 -0
- package/src/email/previews/react/copy.pt-BR.ts +24 -0
- package/src/email/previews/react/copy.ts +42 -0
- package/src/email/previews/react/index.tsx +14 -0
- package/src/email/previews/react/loadable.tsx +83 -0
- package/src/email/previews/react/message-list.tsx +133 -0
- package/src/email/previews/react/message-view.tsx +167 -0
- package/src/email/previews/react/preview-screen.tsx +342 -0
- package/src/email/previews/react/transport.ts +60 -0
- package/src/email/previews/routes.ts +111 -0
- package/src/email/template.ts +365 -0
- package/src/email/text.ts +59 -0
- package/src/email/theme.ts +77 -0
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/react/create-web-notifications.tsx +15 -1
- 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 +141 -0
- package/src/react/panel-lazy.tsx +3 -0
- package/src/react/panel.tsx +57 -10
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/email.ts +62 -0
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-7234ROOP.js.map +0 -1
- package/dist/chunk-BGI4BQ66.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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's half of live activities: where they come from, and what they say.
|
|
3
|
+
*
|
|
4
|
+
* Both are the host's because neither can be this package's. It has no idea
|
|
5
|
+
* what is happening — only the application does — and it has no words, for the
|
|
6
|
+
* same reason `NotificationMessages` is required rather than defaulted: a
|
|
7
|
+
* package that ships one product's sentences ships them to every other product
|
|
8
|
+
* too, and the adopter reached by saying nothing is the one who never notices.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
|
|
12
|
+
import type { LiveActivity } from '../live';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the surface gets the activities that are live RIGHT NOW.
|
|
16
|
+
*
|
|
17
|
+
* A HOOK rather than a fetcher, and rather than a factory-time `subscribe`,
|
|
18
|
+
* because the answer almost always lives in React context: the tenant, the
|
|
19
|
+
* session, the host's query client. `NotificationsSignalHook` exists for
|
|
20
|
+
* exactly this reason one seam over, and a host in that shape had no way to
|
|
21
|
+
* pass anything at all.
|
|
22
|
+
*
|
|
23
|
+
* `active` is whether the surface currently needs the answer. It is a HINT
|
|
24
|
+
* about need, never about correctness: a host that ignores it and always
|
|
25
|
+
* answers is behaving correctly and merely paying for it.
|
|
26
|
+
*
|
|
27
|
+
* What it is NOT is the only thing standing between a shut panel and a query.
|
|
28
|
+
* The panel is fetched lazily and renders nothing until somebody first opens
|
|
29
|
+
* the bell, and the drawer unmounts its content on close — so a host that
|
|
30
|
+
* simply reads `active` and ignores it still issues nothing while the panel is
|
|
31
|
+
* away. `active` is `false` for the frames of the closing transition, which is
|
|
32
|
+
* where it earns its keep: a query told to stand down there does not fire one
|
|
33
|
+
* last time on the way out.
|
|
34
|
+
*
|
|
35
|
+
* Return whatever is live, newest activity first or in whatever order the host
|
|
36
|
+
* means; the surface renders them in the order given. An empty array is the
|
|
37
|
+
* normal answer and renders nothing — no heading, no empty state, no gap.
|
|
38
|
+
*/
|
|
39
|
+
export type LiveActivitiesHook = (options: {
|
|
40
|
+
active: boolean;
|
|
41
|
+
}) => readonly LiveActivity[];
|
|
42
|
+
|
|
43
|
+
/** The three sentences the live section says. */
|
|
44
|
+
export interface LiveActivityMessages {
|
|
45
|
+
/** The heading over the pinned entries, e.g. "Em andamento". */
|
|
46
|
+
sectionTitle: string;
|
|
47
|
+
/** The link's accessible name, e.g. `(title) => `Abrir ${title}``. */
|
|
48
|
+
openActivity: (title: string) => string;
|
|
49
|
+
/**
|
|
50
|
+
* The "last moved" line, given an already-relative time.
|
|
51
|
+
*
|
|
52
|
+
* Takes the phrase rather than the instant so the relative wording stays in
|
|
53
|
+
* ONE place — `relativeTime` and the inbox rows' `há 5 min` — and a host
|
|
54
|
+
* cannot end up with two vocabularies for the same duration in one panel.
|
|
55
|
+
*/
|
|
56
|
+
updated: (relative: string) => string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Live activities, as a host turns them on. */
|
|
60
|
+
export interface LiveActivitiesConfig {
|
|
61
|
+
useActivities: LiveActivitiesHook;
|
|
62
|
+
messages: LiveActivityMessages;
|
|
63
|
+
/**
|
|
64
|
+
* The mark on the left of a card — the fastest read, before any words.
|
|
65
|
+
*
|
|
66
|
+
* A node rather than a field on {@link LiveActivity} so the contract stays
|
|
67
|
+
* framework-free: the root entry is shared with the server half, and a
|
|
68
|
+
* `ReactNode` in it would put React on that import path for a backend that
|
|
69
|
+
* only ever writes rows. The host switches on `kind`, which is what `kind` is
|
|
70
|
+
* for. No renderer, no mark, and the card is text — never a placeholder box.
|
|
71
|
+
*
|
|
72
|
+
* **Return something PRESENTATIONAL.** The mark is drawn inside the card's
|
|
73
|
+
* own `<button>` and inside an `aria-hidden` wrapper, so a focusable node
|
|
74
|
+
* here is a button inside a button — invalid HTML, and the exact defect the
|
|
75
|
+
* card's structure exists to prevent — as well as a control hidden from the
|
|
76
|
+
* accessibility tree. An icon or an `<svg>`; not a control.
|
|
77
|
+
*/
|
|
78
|
+
renderIcon?: (activity: LiveActivity) => ReactNode;
|
|
79
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned block at the top of the panel: everything that is happening NOW,
|
|
3
|
+
* above everything that has already happened.
|
|
4
|
+
*
|
|
5
|
+
* ## Why it is here and not a second surface
|
|
6
|
+
*
|
|
7
|
+
* The notification centre is where a person goes to find out what they missed.
|
|
8
|
+
* Splitting "happening" into its own bell would make them check two places to
|
|
9
|
+
* answer one question, and the half they would stop checking is the one that
|
|
10
|
+
* only has something in it occasionally — which is this one. Above the list,
|
|
11
|
+
* inside the same drawer, it is on the path they already walk.
|
|
12
|
+
*
|
|
13
|
+
* ## What it deliberately does NOT do
|
|
14
|
+
*
|
|
15
|
+
* - It does not touch `unread`. A live entry is not news; counting it would put
|
|
16
|
+
* a number on the bell that no amount of reading can clear.
|
|
17
|
+
* - It renders nothing at all when there is nothing live — no heading, no empty
|
|
18
|
+
* state, no reserved space. A panel with one permanent empty section in it is
|
|
19
|
+
* a panel that has taught its reader to skip the top.
|
|
20
|
+
* - It does not fetch. `useActivities` is the host's, and `active` tells it
|
|
21
|
+
* whether anyone is looking.
|
|
22
|
+
*/
|
|
23
|
+
import { useEffect, useId, useState, type JSX } from 'react';
|
|
24
|
+
|
|
25
|
+
import { Box } from '@12-apps/ui/mui/Box';
|
|
26
|
+
import { Text } from '@12-apps/ui/typography/Text';
|
|
27
|
+
|
|
28
|
+
import type { LiveActivity } from '../live';
|
|
29
|
+
import type { NotificationMessages } from '../messages';
|
|
30
|
+
|
|
31
|
+
import { LiveActivityCard } from './live-card';
|
|
32
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How often the section re-reads the clock.
|
|
36
|
+
*
|
|
37
|
+
* Every minute, because the timestamps under the cards are in minutes and a
|
|
38
|
+
* tick that cannot change what is on screen is a wasted render — which is why
|
|
39
|
+
* it is gated on there being something to tick as well as on the panel being
|
|
40
|
+
* open. An open panel with nothing live schedules nothing at all; the earlier
|
|
41
|
+
* gate was `active` alone, and it re-rendered a section that renders `null`
|
|
42
|
+
* once a minute for as long as somebody left the inbox open.
|
|
43
|
+
*/
|
|
44
|
+
const TICK_MS = 60_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The current minute, re-read on a timer while there is something to tick.
|
|
48
|
+
*
|
|
49
|
+
* The caller passes `active && there are activities` — see {@link TICK_MS} for
|
|
50
|
+
* why both halves are in it.
|
|
51
|
+
*/
|
|
52
|
+
function useMinuteTick(active: boolean): number {
|
|
53
|
+
const [now, setNow] = useState(() => Date.now());
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!active) return;
|
|
56
|
+
// Re-read once on becoming active too: a panel reopened after ten minutes
|
|
57
|
+
// would otherwise show the minute it was closed at until the first tick.
|
|
58
|
+
setNow(Date.now());
|
|
59
|
+
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
|
|
60
|
+
return () => clearInterval(timer);
|
|
61
|
+
}, [active]);
|
|
62
|
+
return now;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LiveSectionProps {
|
|
66
|
+
config: LiveActivitiesConfig;
|
|
67
|
+
messages: NotificationMessages;
|
|
68
|
+
/** Whether the panel is open — passed straight through to the host's hook. */
|
|
69
|
+
active: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Follow a card's link.
|
|
72
|
+
*
|
|
73
|
+
* Optional, and the panel omits it for a host with no router: a card that
|
|
74
|
+
* cannot go anywhere renders as text rather than as a named control that
|
|
75
|
+
* does nothing.
|
|
76
|
+
*/
|
|
77
|
+
onOpen?: (activity: LiveActivity) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
export function LiveSection({
|
|
83
|
+
config,
|
|
84
|
+
messages,
|
|
85
|
+
active,
|
|
86
|
+
onOpen,
|
|
87
|
+
}: LiveSectionProps): JSX.Element | null {
|
|
88
|
+
// Unconditional, because it is a hook. `active` is how it is told nobody is
|
|
89
|
+
// looking — the same arrangement `useSignal` has one seam over.
|
|
90
|
+
const activities = config.useActivities({ active });
|
|
91
|
+
const now = useMinuteTick(active && activities.length > 0);
|
|
92
|
+
// Per MOUNT, not per module: `LiveSection` is exported, and a host with a
|
|
93
|
+
// desktop and a mobile panel would otherwise emit one id twice and have both
|
|
94
|
+
// regions resolve their label to whichever came first.
|
|
95
|
+
const headingId = useId();
|
|
96
|
+
|
|
97
|
+
if (activities.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
// A NAMED region. Without the label a screen-reader user meets a loose run
|
|
101
|
+
// of controls ahead of the inbox with nothing saying what they are; the
|
|
102
|
+
// panel's own title is the drawer's heading and cannot describe this block.
|
|
103
|
+
<Box
|
|
104
|
+
component="section"
|
|
105
|
+
aria-labelledby={headingId}
|
|
106
|
+
data-testid="live-activities"
|
|
107
|
+
sx={{ pb: 1.5 }}
|
|
108
|
+
>
|
|
109
|
+
{/*
|
|
110
|
+
A SPAN, not a heading. `aria-labelledby` names the region perfectly well
|
|
111
|
+
from one, and an `<h2>` here would sit under the drawer's own `<h6>`
|
|
112
|
+
title and ABOVE the inbox's `<h3>` empty state — an outline in which the
|
|
113
|
+
inbox's states read as part of the live block, which is the opposite of
|
|
114
|
+
what the two blocks are.
|
|
115
|
+
*/}
|
|
116
|
+
<Text
|
|
117
|
+
id={headingId}
|
|
118
|
+
variant="caption"
|
|
119
|
+
size="xs"
|
|
120
|
+
color="secondary"
|
|
121
|
+
weight="semibold"
|
|
122
|
+
as="span"
|
|
123
|
+
>
|
|
124
|
+
{config.messages.sectionTitle}
|
|
125
|
+
</Text>
|
|
126
|
+
<Box sx={{ pt: 0.75 }}>
|
|
127
|
+
{activities.map((activity) => (
|
|
128
|
+
<LiveActivityCard
|
|
129
|
+
key={activity.id}
|
|
130
|
+
activity={activity}
|
|
131
|
+
messages={messages}
|
|
132
|
+
live={config.messages}
|
|
133
|
+
now={now}
|
|
134
|
+
{...(onOpen ? { onOpen } : {})}
|
|
135
|
+
{...(config.renderIcon ? { renderIcon: config.renderIcon } : {})}
|
|
136
|
+
/>
|
|
137
|
+
))}
|
|
138
|
+
</Box>
|
|
139
|
+
</Box>
|
|
140
|
+
);
|
|
141
|
+
}
|
package/src/react/panel-lazy.tsx
CHANGED
|
@@ -36,12 +36,15 @@ import { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } fro
|
|
|
36
36
|
import type { NotificationMessages } from '../messages';
|
|
37
37
|
|
|
38
38
|
import type { InboxStore } from './inbox-state';
|
|
39
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
39
40
|
import type { NotificationsPanelProps } from './panel';
|
|
40
41
|
|
|
41
42
|
/** What the factory binds into the panel, and the host never passes. */
|
|
42
43
|
interface PanelParts {
|
|
43
44
|
store: InboxStore;
|
|
44
45
|
messages: NotificationMessages;
|
|
46
|
+
/** Absent unless the host turned live activities on — see `./live-config`. */
|
|
47
|
+
live?: LiveActivitiesConfig;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export function lazyNotificationsPanel(
|
package/src/react/panel.tsx
CHANGED
|
@@ -17,12 +17,15 @@ import { Box } from '@12-apps/ui/mui/Box';
|
|
|
17
17
|
import { useMediaQuery } from '@12-apps/ui/mui/useMediaQuery';
|
|
18
18
|
import { useTheme } from '@12-apps/ui/mui/styles';
|
|
19
19
|
|
|
20
|
+
import type { LiveActivity } from '../live';
|
|
20
21
|
import type { NotificationMessages } from '../messages';
|
|
21
22
|
import type { InboxNotification } from '../wire';
|
|
22
23
|
|
|
23
24
|
import { BellIcon } from './bell-icon';
|
|
24
25
|
import { useInboxList } from './hooks';
|
|
25
26
|
import type { InboxState, InboxStore } from './inbox-state';
|
|
27
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
28
|
+
import { LiveSection } from './live-section';
|
|
26
29
|
import { NotificationRow } from './row';
|
|
27
30
|
|
|
28
31
|
interface PanelBodyProps {
|
|
@@ -105,6 +108,43 @@ function PanelBody({
|
|
|
105
108
|
);
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The two open gestures, which are ONE deep-link path with a mark-read in front
|
|
113
|
+
* of half of it.
|
|
114
|
+
*
|
|
115
|
+
* Lifted out of the component because both kinds of entry follow a link the
|
|
116
|
+
* same way and a host with no router follows neither — one rule, stated once,
|
|
117
|
+
* rather than the same three lines written twice.
|
|
118
|
+
*/
|
|
119
|
+
function usePanelOpeners(
|
|
120
|
+
store: InboxStore,
|
|
121
|
+
onClose: () => void,
|
|
122
|
+
onNavigate?: (link: string) => void,
|
|
123
|
+
): {
|
|
124
|
+
openNotification: (notification: InboxNotification) => void;
|
|
125
|
+
openLive: (activity: LiveActivity) => void;
|
|
126
|
+
} {
|
|
127
|
+
const follow = useCallback(
|
|
128
|
+
(link: string | null) => {
|
|
129
|
+
if (!link || !onNavigate) return;
|
|
130
|
+
onClose();
|
|
131
|
+
onNavigate(link);
|
|
132
|
+
},
|
|
133
|
+
[onClose, onNavigate],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
openNotification: useCallback(
|
|
138
|
+
(notification: InboxNotification) => {
|
|
139
|
+
if (notification.readAt === null) store.markRead([notification.id]);
|
|
140
|
+
follow(notification.link);
|
|
141
|
+
},
|
|
142
|
+
[store, follow],
|
|
143
|
+
),
|
|
144
|
+
openLive: useCallback((activity: LiveActivity) => follow(activity.link), [follow]),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
108
148
|
export interface NotificationsPanelProps {
|
|
109
149
|
open: boolean;
|
|
110
150
|
onClose: () => void;
|
|
@@ -118,9 +158,11 @@ export function NotificationsPanel({
|
|
|
118
158
|
onNavigate,
|
|
119
159
|
store,
|
|
120
160
|
messages,
|
|
161
|
+
live,
|
|
121
162
|
}: NotificationsPanelProps & {
|
|
122
163
|
store: InboxStore;
|
|
123
164
|
messages: NotificationMessages;
|
|
165
|
+
live?: LiveActivitiesConfig;
|
|
124
166
|
}): JSX.Element {
|
|
125
167
|
// `useTheme` from @mui/material/styles falls back to the DEFAULT theme when
|
|
126
168
|
// no provider is mounted, where the callback form of `useMediaQuery` would
|
|
@@ -130,16 +172,7 @@ export function NotificationsPanel({
|
|
|
130
172
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
131
173
|
const state = useInboxList(store, open);
|
|
132
174
|
|
|
133
|
-
const openNotification =
|
|
134
|
-
(notification: InboxNotification) => {
|
|
135
|
-
if (notification.readAt === null) store.markRead([notification.id]);
|
|
136
|
-
if (notification.link && onNavigate) {
|
|
137
|
-
onClose();
|
|
138
|
-
onNavigate(notification.link);
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
[store, onClose, onNavigate],
|
|
142
|
-
);
|
|
175
|
+
const { openNotification, openLive } = usePanelOpeners(store, onClose, onNavigate);
|
|
143
176
|
|
|
144
177
|
const hasUnread = state.items.some((item) => item.readAt === null);
|
|
145
178
|
|
|
@@ -154,6 +187,20 @@ export function NotificationsPanel({
|
|
|
154
187
|
>
|
|
155
188
|
<DrawerHeader onClose={onClose}>{messages.panelTitle}</DrawerHeader>
|
|
156
189
|
<DrawerContent>
|
|
190
|
+
{/*
|
|
191
|
+
Above the mark-all control as well as above the list, deliberately:
|
|
192
|
+
"marcar todas como lidas" belongs to the INBOX, and a live entry has
|
|
193
|
+
nothing to mark. A control between the two blocks would read as
|
|
194
|
+
applying to both.
|
|
195
|
+
*/}
|
|
196
|
+
{live ? (
|
|
197
|
+
<LiveSection
|
|
198
|
+
config={live}
|
|
199
|
+
messages={messages}
|
|
200
|
+
active={open}
|
|
201
|
+
{...(onNavigate ? { onOpen: openLive } : {})}
|
|
202
|
+
/>
|
|
203
|
+
) : null}
|
|
157
204
|
{hasUnread ? (
|
|
158
205
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pb: 1 }}>
|
|
159
206
|
<Button
|
|
@@ -4,9 +4,21 @@ import type { NotificationMessages } from '../messages';
|
|
|
4
4
|
* "há 5 min"-style relative timestamp, falling back to an absolute date for
|
|
5
5
|
* anything older than a week. Every word comes from the messages table, so a
|
|
6
6
|
* host in another locale changes the copy and the locale together.
|
|
7
|
+
*
|
|
8
|
+
* `now` is a parameter rather than a read, and the live section is why. A
|
|
9
|
+
* relative phrase is only true for the instant it was computed, so something
|
|
10
|
+
* has to CAUSE the render that recomputes it — and the live entries' own data
|
|
11
|
+
* cannot: a host backed by react-query gets the previous object back whenever a
|
|
12
|
+
* poll is deep-equal (`structuralSharing`, on by default), which within one
|
|
13
|
+
* stage it always is. The section therefore ticks a clock and hands it down.
|
|
14
|
+
* Defaulted, so every existing caller reads the wall clock exactly as before.
|
|
7
15
|
*/
|
|
8
|
-
export function relativeTime(
|
|
9
|
-
|
|
16
|
+
export function relativeTime(
|
|
17
|
+
iso: string,
|
|
18
|
+
messages: NotificationMessages,
|
|
19
|
+
now: number = Date.now(),
|
|
20
|
+
): string {
|
|
21
|
+
const elapsedMs = now - new Date(iso).getTime();
|
|
10
22
|
const minutes = Math.round(elapsedMs / 60_000);
|
|
11
23
|
if (minutes < 1) return messages.justNow;
|
|
12
24
|
if (minutes < 60) return messages.minutesAgo(minutes);
|
|
@@ -5,6 +5,10 @@ import type {
|
|
|
5
5
|
TransportRecipient,
|
|
6
6
|
} from '../../types';
|
|
7
7
|
|
|
8
|
+
import { renderEmail } from '../../email/template';
|
|
9
|
+
import type { EmailChromeCopy } from '../../email/template';
|
|
10
|
+
import type { EmailTheme } from '../../email/theme';
|
|
11
|
+
|
|
8
12
|
import {
|
|
9
13
|
absoluteLink,
|
|
10
14
|
postOrThrow,
|
|
@@ -53,6 +57,31 @@ export interface EmailDriverDeclaration extends DriverDeclarationBase {
|
|
|
53
57
|
* `from` address.
|
|
54
58
|
*/
|
|
55
59
|
linkLabel: string;
|
|
60
|
+
/**
|
|
61
|
+
* Render through `../../email` — the ONE layout — instead of the three bare
|
|
62
|
+
* `<p>` tags below.
|
|
63
|
+
*
|
|
64
|
+
* OPTIONAL, and its absence is the pre-layout behaviour verbatim. That is
|
|
65
|
+
* deliberate rather than timid: `brand` and `chrome` are REQUIRED with no
|
|
66
|
+
* default anywhere in the layout (a package that defaulted them would sign
|
|
67
|
+
* another company's mail, in a language nobody chose), so a required field
|
|
68
|
+
* here would break every host that already declares EMAIL — at runtime, on
|
|
69
|
+
* the first send, which is the worst place to find out.
|
|
70
|
+
*
|
|
71
|
+
* So the seam is opt-in and the ad-hoc path is what remains for a host that
|
|
72
|
+
* has not taken it. `layout.theme` may still be omitted; `../../email/theme`
|
|
73
|
+
* argues why that one asymmetry is allowed.
|
|
74
|
+
*/
|
|
75
|
+
layout?: {
|
|
76
|
+
/** The product name in the header and the footer. */
|
|
77
|
+
brand: string;
|
|
78
|
+
/** The layout's own words. `@12-apps/notifications/email/locales` ships packs. */
|
|
79
|
+
chrome: EmailChromeCopy;
|
|
80
|
+
/** The recipient's language, for the document's `lang` attribute. */
|
|
81
|
+
locale: string;
|
|
82
|
+
/** Defaults to the layout's neutral palette. */
|
|
83
|
+
theme?: EmailTheme;
|
|
84
|
+
};
|
|
56
85
|
logger?: NotificationLogger;
|
|
57
86
|
}
|
|
58
87
|
|
|
@@ -108,6 +137,11 @@ export const EMAIL_DRIVERS: Record<
|
|
|
108
137
|
/**
|
|
109
138
|
* Agnostic content → subject/text/html. The link becomes a trailing CTA, only
|
|
110
139
|
* when an app base URL is configured.
|
|
140
|
+
*
|
|
141
|
+
* Two renderings, and which one runs is the host's choice: with `layout`
|
|
142
|
+
* declared this is the shared document (`../../email`), and without it the
|
|
143
|
+
* three bare `<p>` tags that predate it. See `EmailDriverDeclaration.layout`
|
|
144
|
+
* for why the new path could not simply replace the old one.
|
|
111
145
|
*/
|
|
112
146
|
export function formatEmail(
|
|
113
147
|
content: NotificationContent,
|
|
@@ -115,6 +149,7 @@ export function formatEmail(
|
|
|
115
149
|
): EmailMessage {
|
|
116
150
|
const href = absoluteLink(content.link, declaration.appUrl);
|
|
117
151
|
const label = declaration.linkLabel;
|
|
152
|
+
if (declaration.layout) return layoutEmail(content, declaration.layout, href, label);
|
|
118
153
|
return {
|
|
119
154
|
subject: content.title,
|
|
120
155
|
text: href ? `${content.body}\n\n${href}` : content.body,
|
|
@@ -126,6 +161,33 @@ export function formatEmail(
|
|
|
126
161
|
};
|
|
127
162
|
}
|
|
128
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The same notification as a laid-out document.
|
|
166
|
+
*
|
|
167
|
+
* The layout escapes and builds the plain-text twin from the SAME object, which
|
|
168
|
+
* is the pair of defects the branch above still carries by construction: its
|
|
169
|
+
* text half is assembled separately, and its `href` reaches the anchor through
|
|
170
|
+
* `escapeHtml` rather than through a scheme check.
|
|
171
|
+
*/
|
|
172
|
+
function layoutEmail(
|
|
173
|
+
content: NotificationContent,
|
|
174
|
+
layout: NonNullable<EmailDriverDeclaration['layout']>,
|
|
175
|
+
href: string | null,
|
|
176
|
+
label: string,
|
|
177
|
+
): EmailMessage {
|
|
178
|
+
const rendered = renderEmail({
|
|
179
|
+
subject: content.title,
|
|
180
|
+
heading: content.title,
|
|
181
|
+
paragraphs: [content.body],
|
|
182
|
+
action: href ? { label, href } : undefined,
|
|
183
|
+
chrome: layout.chrome,
|
|
184
|
+
brand: layout.brand,
|
|
185
|
+
locale: layout.locale,
|
|
186
|
+
theme: layout.theme,
|
|
187
|
+
});
|
|
188
|
+
return { subject: rendered.subject, text: rendered.text, html: rendered.html };
|
|
189
|
+
}
|
|
190
|
+
|
|
129
191
|
export function emailTransport(
|
|
130
192
|
declaration: EmailDriverDeclaration,
|
|
131
193
|
extraDrivers: Record<string, (d: EmailDriverDeclaration) => EmailDriver> = {},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { livePushTag } from '../../live';
|
|
1
2
|
import type {
|
|
2
3
|
NotificationContent,
|
|
3
4
|
NotificationLogger,
|
|
@@ -52,6 +53,26 @@ export interface WebPushMessage {
|
|
|
52
53
|
body: string;
|
|
53
54
|
link: string | null;
|
|
54
55
|
data: Record<string, unknown>;
|
|
56
|
+
/**
|
|
57
|
+
* The OS tray tag, or `null` for an ordinary event.
|
|
58
|
+
*
|
|
59
|
+
* Present for a notification about a LIVE SUBJECT (see `../../live`), and
|
|
60
|
+
* this is the whole of the live feature's reach onto a locked phone: a tag
|
|
61
|
+
* makes the next push about the same subject REPLACE the one already in the
|
|
62
|
+
* tray — silently — instead of stacking under it. Four stages then cost one
|
|
63
|
+
* entry and one buzz, and the entry that remains is the current one.
|
|
64
|
+
*
|
|
65
|
+
* {@link formatWebPush} always sets it, `null` included, so on the WIRE the
|
|
66
|
+
* key is always there and a service worker reads one shape. A worker that
|
|
67
|
+
* ignores it keeps today's behaviour exactly, which is what makes this safe
|
|
68
|
+
* to ship ahead of the workers.
|
|
69
|
+
*
|
|
70
|
+
* OPTIONAL in the type, and only in the type: a host that hand-writes its own
|
|
71
|
+
* `NotificationTransport<WebPushMessage>` — a fixture, a second driver —
|
|
72
|
+
* would otherwise stop compiling on a field it has no way to know about and
|
|
73
|
+
* no reason to set.
|
|
74
|
+
*/
|
|
75
|
+
tag?: string | null;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
export interface WebPushDriverDeclaration extends DriverDeclarationBase {
|
|
@@ -120,6 +141,7 @@ export function formatWebPush(content: NotificationContent): WebPushMessage {
|
|
|
120
141
|
body: content.body,
|
|
121
142
|
link: content.link ?? null,
|
|
122
143
|
data: content.data ?? {},
|
|
144
|
+
tag: livePushTag(content.data),
|
|
123
145
|
};
|
|
124
146
|
}
|
|
125
147
|
|
package/src/types.ts
CHANGED
|
@@ -64,7 +64,16 @@ export interface NotificationContent {
|
|
|
64
64
|
body: string;
|
|
65
65
|
/** In-app deep link (a same-origin path such as `/orders/123`). */
|
|
66
66
|
link?: string;
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Structured extras for consumers that want more than text.
|
|
69
|
+
*
|
|
70
|
+
* ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties
|
|
71
|
+
* this notification to a live activity, and the WEB_PUSH transport turns it
|
|
72
|
+
* into the tray `tag` that collapses every push about one subject onto a
|
|
73
|
+
* single entry. A host already using that name for something else acquires
|
|
74
|
+
* that behaviour without asking for it — so it is named here, on the field a
|
|
75
|
+
* generator actually writes, and not only where the feature is documented.
|
|
76
|
+
*/
|
|
68
77
|
data?: Record<string, unknown>;
|
|
69
78
|
}
|
|
70
79
|
|