@12-apps/notifications 4.10.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/dist/{chunk-5Y7QRORV.js → chunk-I5QUMTCN.js} +136 -16
- package/dist/chunk-I5QUMTCN.js.map +1 -0
- package/dist/{chunk-JCVRQ42B.js → chunk-ZY32PC34.js} +15 -10
- package/dist/chunk-ZY32PC34.js.map +1 -0
- package/dist/manifest/web.d.ts +1 -1
- package/dist/manifest/web.js +1 -1
- package/dist/{panel-T36JEMO3.js → panel-OPB3DBLJ.js} +95 -64
- package/dist/panel-OPB3DBLJ.js.map +1 -0
- package/dist/react/index.d.ts +67 -4
- package/dist/react/index.js +2 -2
- package/package.json +2 -2
- package/src/react/bell-button.tsx +112 -16
- package/src/react/create-web-notifications.tsx +26 -5
- package/src/react/live-section.tsx +42 -4
- package/src/react/live-seen.ts +138 -0
- package/src/react/panel-lazy.tsx +3 -0
- package/src/react/panel.tsx +89 -26
- package/dist/chunk-5Y7QRORV.js.map +0 -1
- package/dist/chunk-JCVRQ42B.js.map +0 -1
- package/dist/panel-T36JEMO3.js.map +0 -1
- package/dist/{create-web-notifications-_NVYmlvy.d.ts → create-web-notifications-DV3Y8k7e.d.ts} +12 -12
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/panel.tsx","../src/react/row.tsx"],"sourcesContent":["/**\n * The notification-centre slide-over: newest-first list with unread styling,\n * per-item open (marks read + deep-links), soft delete, mark-all, empty /\n * loading / error states and a \"load more\" cursor pager.\n *\n * Rendering is app-agnostic — the host passes `onNavigate` (its router's\n * navigate) for deep links. Without one a link is simply not followed, which is\n * what lets the panel mount in a host that has no router at all.\n */\nimport { useCallback, type JSX } from 'react';\n\nimport { EmptyState } from '@12-apps/ui/data-display/EmptyState';\nimport { LoadingState } from '@12-apps/ui/data-display/LoadingState';\nimport { Button } from '@12-apps/ui/form/Button';\nimport { Drawer, DrawerContent, DrawerHeader } from '@12-apps/ui/layout/Drawer';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { useMediaQuery } from '@12-apps/ui/mui/useMediaQuery';\nimport { useTheme } from '@12-apps/ui/mui/styles';\n\nimport type { LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\nimport type { InboxNotification } from '../wire';\n\nimport { BellIcon } from './bell-icon';\nimport { useInboxList } from './hooks';\nimport type { InboxState, InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { LiveSeenStore } from './live-seen';\nimport { LiveSection } from './live-section';\nimport { NotificationRow } from './row';\n\ninterface PanelBodyProps {\n state: InboxState;\n messages: NotificationMessages;\n onRetry: () => void;\n onLoadMore: () => void;\n onOpen: (notification: InboxNotification) => void;\n onDelete: (id: string) => void;\n /**\n * Whether the live section above has anything in it.\n *\n * The empty state is a CLAIM about the whole panel — \"nenhuma notificação\" —\n * and a live entry is a notification, so an inbox with no rows under a pinned\n * pedido is not empty. Without this the panel said both things at once.\n */\n hasLive: boolean;\n}\n\n/** The scrollable panel body: loading / error / empty / the list + pager. */\nfunction PanelBody({\n state,\n messages,\n onRetry,\n onLoadMore,\n onOpen,\n onDelete,\n hasLive,\n}: PanelBodyProps): JSX.Element {\n if (state.status === 'pending' || state.status === 'idle') {\n return (\n <LoadingState\n variant=\"spinner\"\n message={messages.loading}\n size=\"md\"\n dataTestId=\"notifications-loading\"\n />\n );\n }\n if (state.status === 'error') {\n return (\n <EmptyState\n variant=\"minimal\"\n title={messages.loadFailedTitle}\n description={messages.loadFailedBody}\n onRefresh={onRetry}\n refreshLabel={messages.retry}\n dataTestId=\"notifications-error\"\n />\n );\n }\n if (state.items.length === 0 && !hasLive) {\n return (\n <EmptyState\n variant=\"illustrated\"\n illustration={<BellIcon size={44} dim />}\n title={messages.emptyTitle}\n description={messages.emptyBody}\n dataTestId=\"notifications-empty\"\n />\n );\n }\n return (\n // A stable anchor for the inbox half, present whether or not it has rows.\n // The panel's claim is that what is HAPPENING sits above what has already\n // happened, and until this existed the only thing below the live section to\n // point at was the empty state — which is exactly what stops rendering when\n // something is live.\n <Box data-testid=\"notifications-inbox\">\n {state.items.map((notification) => (\n <NotificationRow\n key={notification.id}\n notification={notification}\n messages={messages}\n onOpen={onOpen}\n onDelete={onDelete}\n />\n ))}\n {state.nextCursor ? (\n <Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>\n <Button\n variant=\"outline\"\n color=\"neutral\"\n size=\"sm\"\n disabled={state.loadingMore}\n onClick={onLoadMore}\n dataTestId=\"notifications-load-more\"\n >\n {state.loadingMore ? messages.loadingMore : messages.loadMore}\n </Button>\n </Box>\n ) : null}\n </Box>\n );\n}\n\n/**\n * The two open gestures, which are ONE deep-link path with a mark-read in front\n * of half of it.\n *\n * Lifted out of the component because both kinds of entry follow a link the\n * same way and a host with no router follows neither — one rule, stated once,\n * rather than the same three lines written twice.\n */\nfunction usePanelOpeners(\n store: InboxStore,\n onClose: () => void,\n onNavigate?: (link: string) => void,\n): {\n openNotification: (notification: InboxNotification) => void;\n openLive: (activity: LiveActivity) => void;\n} {\n const follow = useCallback(\n (link: string | null) => {\n if (!link || !onNavigate) return;\n onClose();\n onNavigate(link);\n },\n [onClose, onNavigate],\n );\n\n return {\n openNotification: useCallback(\n (notification: InboxNotification) => {\n if (notification.readAt === null) store.markRead([notification.id]);\n follow(notification.link);\n },\n [store, follow],\n ),\n openLive: useCallback((activity: LiveActivity) => follow(activity.link), [follow]),\n };\n}\n\nexport interface NotificationsPanelProps {\n open: boolean;\n onClose: () => void;\n /** Navigate to a notification's in-app link (the host's router). */\n onNavigate?: (link: string) => void;\n}\n\n/**\n * Everything BELOW the live section: the mark-all control and the list.\n *\n * Its own component rather than a block inside the panel because it is rendered\n * from two places — through `LiveSection`, and directly when the host turned\n * live activities off — and because the panel is at its line ceiling. \"Marcar\n * todas como lidas\" belongs to the INBOX and travels with it: a live entry has\n * nothing to mark.\n */\nfunction PanelInbox({\n state,\n messages,\n store,\n onOpen,\n hasLive,\n}: {\n state: InboxState;\n messages: NotificationMessages;\n store: InboxStore;\n onOpen: (notification: InboxNotification) => void;\n hasLive: boolean;\n}): JSX.Element {\n const hasUnread = state.items.some((item) => item.readAt === null);\n return (\n <>\n {hasUnread ? (\n <Box sx={{ display: 'flex', justifyContent: 'flex-end', pb: 1 }}>\n <Button\n variant=\"ghost\"\n color=\"primary\"\n size=\"xs\"\n onClick={() => store.markAllRead()}\n dataTestId=\"notifications-mark-all-read\"\n >\n {messages.markAllRead}\n </Button>\n </Box>\n ) : null}\n <PanelBody\n state={state}\n messages={messages}\n onRetry={() => store.invalidate()}\n onLoadMore={() => store.loadMore()}\n onOpen={onOpen}\n onDelete={(id) => store.remove(id)}\n hasLive={hasLive}\n />\n </>\n );\n}\n\nexport function NotificationsPanel({\n open,\n onClose,\n onNavigate,\n store,\n messages,\n live,\n liveSeen,\n}: NotificationsPanelProps & {\n store: InboxStore;\n messages: NotificationMessages;\n live?: LiveActivitiesConfig;\n liveSeen?: LiveSeenStore;\n}): JSX.Element {\n // `useTheme` from @mui/material/styles falls back to the DEFAULT theme when\n // no provider is mounted, where the callback form of `useMediaQuery` would\n // hand the callback a null theme and throw. A published component must render\n // in a host that has not wrapped it yet.\n const theme = useTheme();\n const isMobile = useMediaQuery(theme.breakpoints.down('sm'));\n const state = useInboxList(store, open);\n\n const { openNotification, openLive } = usePanelOpeners(store, onClose, onNavigate);\n\n // A function rather than an element so the live count can reach it: with a\n // live section it is called by `LiveSection` — the only place the host's hook\n // may be called — and without one it is called here with zero.\n const renderInbox = (liveCount: number): JSX.Element => (\n <PanelInbox\n state={state}\n messages={messages}\n store={store}\n onOpen={openNotification}\n hasLive={liveCount > 0}\n />\n );\n\n return (\n <Drawer\n open={open}\n onClose={onClose}\n anchor=\"right\"\n variant=\"right\"\n width={isMobile ? '100vw' : 400}\n dataTestId=\"notifications-panel\"\n >\n <DrawerHeader onClose={onClose}>{messages.panelTitle}</DrawerHeader>\n <DrawerContent>\n {/*\n Above the mark-all control as well as above the list, deliberately:\n \"marcar todas como lidas\" belongs to the INBOX, and a live entry has\n nothing to mark. A control between the two blocks would read as\n applying to both.\n */}\n {live ? (\n <LiveSection\n config={live}\n messages={messages}\n active={open}\n {...(onNavigate ? { onOpen: openLive } : {})}\n {...(liveSeen ? { seen: liveSeen } : {})}\n >\n {renderInbox}\n </LiveSection>\n ) : (\n renderInbox(0)\n )}\n </DrawerContent>\n </Drawer>\n );\n}\n","/** One inbox row: unread accent, content (opens/marks read), timestamp, delete. */\nimport type { JSX } from 'react';\n\nimport { Button } from '@12-apps/ui/form/Button';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { alpha, type Theme } from '@12-apps/ui/mui/styles';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { NotificationMessages } from '../messages';\nimport type { InboxNotification } from '../wire';\n\nimport { relativeTime } from './relative-time';\n\nconst contentButtonSx = {\n flex: 1,\n minWidth: 0,\n display: 'flex',\n flexDirection: 'column',\n gap: 0.25,\n textAlign: 'left',\n border: 'none',\n background: 'none',\n p: 0,\n cursor: 'pointer',\n color: 'text.primary',\n fontFamily: 'inherit',\n} as const;\n\nconst unreadDotSx = {\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: 'primary.main',\n flex: '0 0 auto',\n} as const;\n\nexport function NotificationRow({\n notification,\n messages,\n onOpen,\n onDelete,\n}: {\n notification: InboxNotification;\n messages: NotificationMessages;\n onOpen: (notification: InboxNotification) => void;\n onDelete: (id: string) => void;\n}): JSX.Element {\n const unread = notification.readAt === null;\n return (\n <Box\n data-testid={`notification-${notification.id}`}\n sx={{\n display: 'flex',\n alignItems: 'flex-start',\n gap: 1,\n py: 1.5,\n px: 1,\n borderBottom: '1px solid',\n borderColor: 'divider',\n bgcolor: unread ? (t: Theme) => alpha(t.palette.primary.main, 0.06) : 'transparent',\n }}\n >\n <Box\n component=\"button\"\n type=\"button\"\n onClick={() => onOpen(notification)}\n aria-label={\n unread ? `${notification.title} (${messages.unreadSuffix})` : notification.title\n }\n sx={contentButtonSx}\n >\n <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>\n {unread ? <Box aria-hidden sx={unreadDotSx} /> : null}\n <Text variant=\"body\" size=\"sm\" weight={unread ? 'bold' : 'medium'} as=\"span\">\n {notification.title}\n </Text>\n </Box>\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\">\n {notification.body}\n </Text>\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\" italic>\n {relativeTime(notification.createdAt, messages)}\n </Text>\n </Box>\n\n <Button\n variant=\"ghost\"\n color=\"neutral\"\n size=\"xs\"\n aria-label={messages.deleteOne(notification.title)}\n onClick={() => onDelete(notification.id)}\n dataTestId={`notification-delete-${notification.id}`}\n >\n ✕\n </Button>\n </Box>\n );\n}\n"],"mappings":"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","names":["Button","Box","jsx","jsxs","Box","Button"]}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
|
-
import { L as LiveActivitiesConfig, N as NotificationsApiClient } from '../create-web-notifications-
|
|
2
|
-
export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BellButtonProps, I as InboxListStatus, d as InboxState, e as InboxStore, f as LiveActivitiesHook, g as LiveActivityMessages, h as NotificationsHttpError, i as NotificationsPanelProps, j as NotificationsResult, k as NotificationsSignalHook, l as NotificationsSubscribe, m as NotificationsTransport, n as NotificationsWebConfig, P as PAGE_SIZE, o as PreferencesPayload, p as PreferencesScreenProps, q as PushRegistrationPayload, W as WebNotifications, r as WebPushPlatformHint, s as WebPushSetupConfig, t as createInboxStore, u as createNotificationsApiClient, c as createWebNotifications, v as httpNotificationsTransport, w as useInboxList, x as useInboxState, y as useUnreadCount } from '../create-web-notifications-
|
|
3
|
-
import { JSX } from 'react';
|
|
1
|
+
import { L as LiveActivitiesConfig, N as NotificationsApiClient } from '../create-web-notifications-DV3Y8k7e.js';
|
|
2
|
+
export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BellButtonProps, I as InboxListStatus, d as InboxState, e as InboxStore, f as LiveActivitiesHook, g as LiveActivityMessages, h as NotificationsHttpError, i as NotificationsPanelProps, j as NotificationsResult, k as NotificationsSignalHook, l as NotificationsSubscribe, m as NotificationsTransport, n as NotificationsWebConfig, P as PAGE_SIZE, o as PreferencesPayload, p as PreferencesScreenProps, q as PushRegistrationPayload, W as WebNotifications, r as WebPushPlatformHint, s as WebPushSetupConfig, t as createInboxStore, u as createNotificationsApiClient, c as createWebNotifications, v as httpNotificationsTransport, w as useInboxList, x as useInboxState, y as useUnreadCount } from '../create-web-notifications-DV3Y8k7e.js';
|
|
3
|
+
import { JSX, ReactNode } from 'react';
|
|
4
4
|
import { b as LiveActivity } from '../live-DYxEFO49.js';
|
|
5
5
|
export { c as LiveActivityLane, d as LiveActivityStep, l as liveActivityLane } from '../live-DYxEFO49.js';
|
|
6
6
|
import { N as NotificationMessages } from '../wire-BG1kuoXX.js';
|
|
7
7
|
import '../types-BlqZkCWZ.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* What the reader has already been shown, so the bell can say NEW rather than
|
|
11
|
+
* merely PRESENT.
|
|
12
|
+
*
|
|
13
|
+
* A live activity is unlike an inbox row in the one way that matters here: it
|
|
14
|
+
* stays on the panel for as long as the thing is happening, so its presence
|
|
15
|
+
* cannot mean "you have not seen this". A pedido that has been `Preparo` for
|
|
16
|
+
* ten minutes is still live and still worth counting, but nothing has happened
|
|
17
|
+
* — and a badge that shouts for a subject the reader has already looked at is a
|
|
18
|
+
* badge people stop reading.
|
|
19
|
+
*
|
|
20
|
+
* So presence and novelty are answered separately: the COUNT comes from how
|
|
21
|
+
* many are live, and the TONE comes from this. The panel writes it — being on
|
|
22
|
+
* screen is what seen means — and the bell reads it.
|
|
23
|
+
*
|
|
24
|
+
* ## Per subject, not one watermark
|
|
25
|
+
*
|
|
26
|
+
* A single "newest instant already seen" is smaller and was the first cut, and
|
|
27
|
+
* it is wrong in a way that shows up in normal use: a pedido placed ten minutes
|
|
28
|
+
* ago but only now reaching the client arrives with an `updatedAt` BEHIND the
|
|
29
|
+
* watermark, and would be silently marked as already seen. The reader has never
|
|
30
|
+
* laid eyes on it. Keyed by subject, an id that has not been recorded is new
|
|
31
|
+
* whatever its clock says.
|
|
32
|
+
*
|
|
33
|
+
* Bounded by pruning rather than by expiry: every write keeps only the subjects
|
|
34
|
+
* that are live at that moment, so the record can never outgrow the number of
|
|
35
|
+
* things happening at once. A subject that finishes and later comes back is
|
|
36
|
+
* news again, which is correct — it is a different occurrence.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** id -> the `updatedAt` that was on screen. */
|
|
40
|
+
type SeenMap = Readonly<Record<string, string>>;
|
|
41
|
+
interface LiveSeenStore {
|
|
42
|
+
/** What has been shown, keyed by subject id. */
|
|
43
|
+
read: () => SeenMap;
|
|
44
|
+
/** Record that exactly these are on screen now, forgetting subjects that are not. */
|
|
45
|
+
mark: (activities: readonly LiveActivity[]) => void;
|
|
46
|
+
subscribe: (listener: () => void) => () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
9
49
|
/** Inline SVG bell (no icon-library dependency in this package). */
|
|
10
50
|
|
|
11
51
|
declare function BellIcon({ size, dim, }: {
|
|
@@ -49,8 +89,31 @@ interface LiveSectionProps {
|
|
|
49
89
|
* does nothing.
|
|
50
90
|
*/
|
|
51
91
|
onOpen?: (activity: LiveActivity) => void;
|
|
92
|
+
/**
|
|
93
|
+
* The rest of the panel, given how many entries are live.
|
|
94
|
+
*
|
|
95
|
+
* A render prop rather than a sibling, because the count is knowable only
|
|
96
|
+
* where the host's hook is CALLED, and it cannot be called anywhere else:
|
|
97
|
+
* `live` is optional on the panel, so reading it there would mean calling a
|
|
98
|
+
* hook conditionally — the failure React reports as a crash in some unrelated
|
|
99
|
+
* component.
|
|
100
|
+
*
|
|
101
|
+
* The inbox needs the number for exactly one decision, and it is the decision
|
|
102
|
+
* this section exists to inform: whether "no notifications" is true. A live
|
|
103
|
+
* entry IS a notification, so a panel showing one under that sentence is
|
|
104
|
+
* contradicting itself.
|
|
105
|
+
*/
|
|
106
|
+
children?: (liveCount: number) => ReactNode;
|
|
107
|
+
/**
|
|
108
|
+
* Where "the reader has seen these" is recorded, for the bell to read.
|
|
109
|
+
*
|
|
110
|
+
* Written HERE because this is the component that puts them on screen, and
|
|
111
|
+
* being on screen is what seen means. Optional so the section stays usable by
|
|
112
|
+
* a host that mounts it outside the panel.
|
|
113
|
+
*/
|
|
114
|
+
seen?: LiveSeenStore;
|
|
52
115
|
}
|
|
53
|
-
declare function LiveSection({ config, messages, active, onOpen, }: LiveSectionProps): JSX.Element
|
|
116
|
+
declare function LiveSection({ config, messages, active, onOpen, children, seen, }: LiveSectionProps): JSX.Element;
|
|
54
117
|
|
|
55
118
|
/**
|
|
56
119
|
* "há 5 min"-style relative timestamp, falling back to an absolute date for
|
package/dist/react/index.js
CHANGED
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
createNotificationsApiClient,
|
|
4
4
|
createWebNotifications,
|
|
5
5
|
httpNotificationsTransport
|
|
6
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-I5QUMTCN.js";
|
|
7
7
|
import {
|
|
8
8
|
LiveSection,
|
|
9
9
|
relativeTime
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-ZY32PC34.js";
|
|
11
11
|
import {
|
|
12
12
|
BADGE_POLL_MS,
|
|
13
13
|
BADGE_RECONCILE_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@12-apps/ui": "^6.
|
|
74
|
+
"@12-apps/ui": "^6.18.0"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@12-apps/wiring": ">=1.3.0",
|
|
@@ -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,7 +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 { 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,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './hooks';
|
|
12
12
|
import { createInboxStore, type InboxStore } from './inbox-state';
|
|
13
13
|
import type { LiveActivitiesConfig } from './live-config';
|
|
14
|
+
import { createLiveSeenStore } from './live-seen';
|
|
14
15
|
import { lazyNotificationsPanel } from './panel-lazy';
|
|
15
16
|
import type { NotificationsPanelProps } from './panel';
|
|
16
17
|
import { lazyPreferencesPage } from './page-lazy';
|
|
@@ -117,13 +118,33 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
|
|
|
117
118
|
...(config.useSignal ? { useSignal: config.useSignal } : {}),
|
|
118
119
|
};
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
);
|
|
123
144
|
const Panel = lazyNotificationsPanel({
|
|
124
145
|
store,
|
|
125
146
|
messages,
|
|
126
|
-
...(
|
|
147
|
+
...(live ? { live, liveSeen } : {}),
|
|
127
148
|
});
|
|
128
149
|
|
|
129
150
|
function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* - It does not fetch. `useActivities` is the host's, and `active` tells it
|
|
21
21
|
* whether anyone is looking.
|
|
22
22
|
*/
|
|
23
|
-
import { useEffect, useId, useState, type JSX } from 'react';
|
|
23
|
+
import { useEffect, useId, useState, type JSX, type ReactNode } from 'react';
|
|
24
24
|
|
|
25
25
|
import { Box } from '@12-apps/ui/mui/Box';
|
|
26
26
|
import { Text } from '@12-apps/ui/typography/Text';
|
|
@@ -30,6 +30,7 @@ import type { NotificationMessages } from '../messages';
|
|
|
30
30
|
|
|
31
31
|
import { LiveActivityCard } from './live-card';
|
|
32
32
|
import type { LiveActivitiesConfig } from './live-config';
|
|
33
|
+
import type { LiveSeenStore } from './live-seen';
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* How often the section re-reads the clock.
|
|
@@ -75,6 +76,29 @@ export interface LiveSectionProps {
|
|
|
75
76
|
* does nothing.
|
|
76
77
|
*/
|
|
77
78
|
onOpen?: (activity: LiveActivity) => void;
|
|
79
|
+
/**
|
|
80
|
+
* The rest of the panel, given how many entries are live.
|
|
81
|
+
*
|
|
82
|
+
* A render prop rather than a sibling, because the count is knowable only
|
|
83
|
+
* where the host's hook is CALLED, and it cannot be called anywhere else:
|
|
84
|
+
* `live` is optional on the panel, so reading it there would mean calling a
|
|
85
|
+
* hook conditionally — the failure React reports as a crash in some unrelated
|
|
86
|
+
* component.
|
|
87
|
+
*
|
|
88
|
+
* The inbox needs the number for exactly one decision, and it is the decision
|
|
89
|
+
* this section exists to inform: whether "no notifications" is true. A live
|
|
90
|
+
* entry IS a notification, so a panel showing one under that sentence is
|
|
91
|
+
* contradicting itself.
|
|
92
|
+
*/
|
|
93
|
+
children?: (liveCount: number) => ReactNode;
|
|
94
|
+
/**
|
|
95
|
+
* Where "the reader has seen these" is recorded, for the bell to read.
|
|
96
|
+
*
|
|
97
|
+
* Written HERE because this is the component that puts them on screen, and
|
|
98
|
+
* being on screen is what seen means. Optional so the section stays usable by
|
|
99
|
+
* a host that mounts it outside the panel.
|
|
100
|
+
*/
|
|
101
|
+
seen?: LiveSeenStore;
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
|
|
@@ -84,7 +108,9 @@ export function LiveSection({
|
|
|
84
108
|
messages,
|
|
85
109
|
active,
|
|
86
110
|
onOpen,
|
|
87
|
-
|
|
111
|
+
children,
|
|
112
|
+
seen,
|
|
113
|
+
}: LiveSectionProps): JSX.Element {
|
|
88
114
|
// Unconditional, because it is a hook. `active` is how it is told nobody is
|
|
89
115
|
// looking — the same arrangement `useSignal` has one seam over.
|
|
90
116
|
const activities = config.useActivities({ active });
|
|
@@ -94,9 +120,19 @@ export function LiveSection({
|
|
|
94
120
|
// regions resolve their label to whichever came first.
|
|
95
121
|
const headingId = useId();
|
|
96
122
|
|
|
97
|
-
|
|
123
|
+
const liveCount = activities.length;
|
|
124
|
+
|
|
125
|
+
// Only while somebody is looking. The panel keeps this mounted through the
|
|
126
|
+
// closing transition, and marking there would swallow an update that arrived
|
|
127
|
+
// in the frames after the reader turned away.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (active && liveCount > 0) seen?.mark(activities);
|
|
130
|
+
}, [active, liveCount, activities, seen]);
|
|
131
|
+
|
|
132
|
+
if (liveCount === 0) return <>{children?.(0)}</>;
|
|
98
133
|
|
|
99
134
|
return (
|
|
135
|
+
<>
|
|
100
136
|
// A NAMED region. Without the label a screen-reader user meets a loose run
|
|
101
137
|
// of controls ahead of the inbox with nothing saying what they are; the
|
|
102
138
|
// panel's own title is the drawer's heading and cannot describe this block.
|
|
@@ -136,6 +172,8 @@ export function LiveSection({
|
|
|
136
172
|
/>
|
|
137
173
|
))}
|
|
138
174
|
</Box>
|
|
139
|
-
|
|
175
|
+
</Box>
|
|
176
|
+
{children?.(liveCount)}
|
|
177
|
+
</>
|
|
140
178
|
);
|
|
141
179
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reader has already been shown, so the bell can say NEW rather than
|
|
3
|
+
* merely PRESENT.
|
|
4
|
+
*
|
|
5
|
+
* A live activity is unlike an inbox row in the one way that matters here: it
|
|
6
|
+
* stays on the panel for as long as the thing is happening, so its presence
|
|
7
|
+
* cannot mean "you have not seen this". A pedido that has been `Preparo` for
|
|
8
|
+
* ten minutes is still live and still worth counting, but nothing has happened
|
|
9
|
+
* — and a badge that shouts for a subject the reader has already looked at is a
|
|
10
|
+
* badge people stop reading.
|
|
11
|
+
*
|
|
12
|
+
* So presence and novelty are answered separately: the COUNT comes from how
|
|
13
|
+
* many are live, and the TONE comes from this. The panel writes it — being on
|
|
14
|
+
* screen is what seen means — and the bell reads it.
|
|
15
|
+
*
|
|
16
|
+
* ## Per subject, not one watermark
|
|
17
|
+
*
|
|
18
|
+
* A single "newest instant already seen" is smaller and was the first cut, and
|
|
19
|
+
* it is wrong in a way that shows up in normal use: a pedido placed ten minutes
|
|
20
|
+
* ago but only now reaching the client arrives with an `updatedAt` BEHIND the
|
|
21
|
+
* watermark, and would be silently marked as already seen. The reader has never
|
|
22
|
+
* laid eyes on it. Keyed by subject, an id that has not been recorded is new
|
|
23
|
+
* whatever its clock says.
|
|
24
|
+
*
|
|
25
|
+
* Bounded by pruning rather than by expiry: every write keeps only the subjects
|
|
26
|
+
* that are live at that moment, so the record can never outgrow the number of
|
|
27
|
+
* things happening at once. A subject that finishes and later comes back is
|
|
28
|
+
* news again, which is correct — it is a different occurrence.
|
|
29
|
+
*/
|
|
30
|
+
import type { LiveActivity } from '../live';
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = '12a.notifications.live-seen';
|
|
33
|
+
|
|
34
|
+
/** id -> the `updatedAt` that was on screen. */
|
|
35
|
+
type SeenMap = Readonly<Record<string, string>>;
|
|
36
|
+
|
|
37
|
+
const EMPTY: SeenMap = {};
|
|
38
|
+
|
|
39
|
+
/** ms since epoch, or `null` for an absent or unparseable stamp. */
|
|
40
|
+
function instant(iso: string | undefined): number | null {
|
|
41
|
+
if (iso === undefined) return null;
|
|
42
|
+
const ms = Date.parse(iso);
|
|
43
|
+
return Number.isNaN(ms) ? null : ms;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read/write through `try`, every time.
|
|
48
|
+
*
|
|
49
|
+
* `localStorage` is not merely absent in SSR and in a worker — the ACCESSOR
|
|
50
|
+
* itself throws in a browser set to block site data. A notification bell that
|
|
51
|
+
* cannot render because storage is blocked is a worse failure than one that
|
|
52
|
+
* forgets what was seen, and forgetting degrades in the safe direction: towards
|
|
53
|
+
* saying something is happening.
|
|
54
|
+
*/
|
|
55
|
+
function readStored(): SeenMap {
|
|
56
|
+
try {
|
|
57
|
+
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
|
|
58
|
+
if (!raw) return EMPTY;
|
|
59
|
+
const parsed: unknown = JSON.parse(raw);
|
|
60
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return EMPTY;
|
|
61
|
+
// Anything can be in storage — another version of this package, or a person
|
|
62
|
+
// with the devtools open. Keep only what has the shape this reads.
|
|
63
|
+
const clean: Record<string, string> = {};
|
|
64
|
+
for (const [id, value] of Object.entries(parsed)) {
|
|
65
|
+
if (typeof value === 'string') clean[id] = value;
|
|
66
|
+
}
|
|
67
|
+
return clean;
|
|
68
|
+
} catch {
|
|
69
|
+
return EMPTY;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeStored(value: SeenMap): void {
|
|
74
|
+
try {
|
|
75
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value));
|
|
76
|
+
} catch {
|
|
77
|
+
// Blocked or full. The badge stays new a while longer; nothing else breaks.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface LiveSeenStore {
|
|
82
|
+
/** What has been shown, keyed by subject id. */
|
|
83
|
+
read: () => SeenMap;
|
|
84
|
+
/** Record that exactly these are on screen now, forgetting subjects that are not. */
|
|
85
|
+
mark: (activities: readonly LiveActivity[]) => void;
|
|
86
|
+
subscribe: (listener: () => void) => () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createLiveSeenStore(): LiveSeenStore {
|
|
90
|
+
// Mirrored in memory as well as in storage: `useSyncExternalStore` compares
|
|
91
|
+
// snapshots by IDENTITY and calls `read` on every render, so parsing storage
|
|
92
|
+
// there would hand it a fresh object each time and re-render for ever.
|
|
93
|
+
let current = readStored();
|
|
94
|
+
const listeners = new Set<() => void>();
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
read: () => current,
|
|
98
|
+
mark: (activities) => {
|
|
99
|
+
const next: Record<string, string> = {};
|
|
100
|
+
for (const activity of activities) next[activity.id] = activity.updatedAt;
|
|
101
|
+
// Identity is the snapshot, so an unchanged map must not become a new
|
|
102
|
+
// object — see `read` above.
|
|
103
|
+
const ids = Object.keys(next);
|
|
104
|
+
const same =
|
|
105
|
+
ids.length === Object.keys(current).length &&
|
|
106
|
+
ids.every((id) => current[id] === next[id]);
|
|
107
|
+
if (same) return;
|
|
108
|
+
current = next;
|
|
109
|
+
writeStored(next);
|
|
110
|
+
for (const listener of listeners) listener();
|
|
111
|
+
},
|
|
112
|
+
subscribe: (listener) => {
|
|
113
|
+
listeners.add(listener);
|
|
114
|
+
return () => {
|
|
115
|
+
listeners.delete(listener);
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether any of these has moved, or arrived, since the reader last looked.
|
|
123
|
+
*
|
|
124
|
+
* An id with nothing recorded is new — that is the case the per-subject record
|
|
125
|
+
* exists for. An unparseable stamp is treated as new too: the alternative is
|
|
126
|
+
* silently never alerting for a host whose clock format this does not read.
|
|
127
|
+
*/
|
|
128
|
+
export function hasUnseenActivity(
|
|
129
|
+
activities: readonly LiveActivity[],
|
|
130
|
+
seen: SeenMap,
|
|
131
|
+
): boolean {
|
|
132
|
+
return activities.some((activity) => {
|
|
133
|
+
const shown = instant(seen[activity.id]);
|
|
134
|
+
if (shown === null) return true;
|
|
135
|
+
const now = instant(activity.updatedAt);
|
|
136
|
+
return now === null || now > shown;
|
|
137
|
+
});
|
|
138
|
+
}
|
package/src/react/panel-lazy.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import type { NotificationMessages } from '../messages';
|
|
|
37
37
|
|
|
38
38
|
import type { InboxStore } from './inbox-state';
|
|
39
39
|
import type { LiveActivitiesConfig } from './live-config';
|
|
40
|
+
import type { LiveSeenStore } from './live-seen';
|
|
40
41
|
import type { NotificationsPanelProps } from './panel';
|
|
41
42
|
|
|
42
43
|
/** What the factory binds into the panel, and the host never passes. */
|
|
@@ -45,6 +46,8 @@ interface PanelParts {
|
|
|
45
46
|
messages: NotificationMessages;
|
|
46
47
|
/** Absent unless the host turned live activities on — see `./live-config`. */
|
|
47
48
|
live?: LiveActivitiesConfig;
|
|
49
|
+
/** Travels with `live`: where the panel records what the reader has seen. */
|
|
50
|
+
liveSeen?: LiveSeenStore;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
export function lazyNotificationsPanel(
|