@12-apps/notifications 4.10.2 → 4.11.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 +47 -5
- package/README.md +7 -5
- package/dist/{chunk-BW723CX2.js → chunk-2IAHFIXS.js} +51 -38
- package/dist/chunk-2IAHFIXS.js.map +1 -0
- package/dist/{chunk-I5QUMTCN.js → chunk-2TJ4D2KE.js} +38 -27
- package/dist/chunk-2TJ4D2KE.js.map +1 -0
- package/dist/{chunk-ZFIYBNZ7.js → chunk-KEYRE245.js} +3 -6
- package/dist/chunk-KEYRE245.js.map +1 -0
- package/dist/{chunk-ZY32PC34.js → chunk-MPUOVQVX.js} +2 -4
- package/dist/chunk-MPUOVQVX.js.map +1 -0
- package/dist/{create-web-notifications-DV3Y8k7e.d.ts → create-web-notifications-CnaXx6km.d.ts} +59 -12
- package/dist/email/previews/react/index.js +1 -1
- package/dist/manifest/web.d.ts +1 -1
- package/dist/manifest/web.js +3 -3
- package/dist/{panel-OPB3DBLJ.js → panel-MKI4PTNZ.js} +3 -3
- package/dist/react/index.d.ts +27 -7
- package/dist/react/index.js +3 -3
- package/package.json +2 -2
- package/src/email/previews/react/loadable.tsx +4 -1
- package/src/email/previews/react/preview-screen.tsx +4 -4
- package/src/react/bell-badge.ts +147 -0
- package/src/react/bell-button.tsx +19 -31
- package/src/react/create-web-notifications.tsx +55 -6
- package/src/react/hooks.ts +61 -13
- package/src/react/index.ts +13 -0
- package/src/react/live-section.tsx +32 -14
- package/dist/chunk-BW723CX2.js.map +0 -1
- package/dist/chunk-I5QUMTCN.js.map +0 -1
- package/dist/chunk-ZFIYBNZ7.js.map +0 -1
- package/dist/chunk-ZY32PC34.js.map +0 -1
- /package/dist/{panel-OPB3DBLJ.js.map → panel-MKI4PTNZ.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/email/previews/react/message-view.tsx","../src/email/previews/react/preview-screen.tsx","../src/email/previews/react/loadable.tsx","../src/email/previews/react/message-list.tsx","../src/email/previews/react/transport.ts"],"sourcesContent":["import type { JSX } from 'react';\n\nimport { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewDetail } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * One rendered message, in the three ways it is worth looking at.\n *\n * - **HTML** — what most people will see, in a sandboxed frame at either of the\n * two widths that matter. A phone is not a nice-to-have: more than half of\n * transactional mail is opened on one, and the 600px card is exactly the\n * thing that either survives that or does not.\n * - **Text** — the plain-text twin. Worth its own tab because it is what a spam\n * filter scores, what a watch shows, and what a screen reader in plain-text\n * mode reads — and because it is the half nobody ever looks at, which is how\n * it drifts out of step with the HTML.\n * - **Source** — the markup itself, for the moment somebody is debugging why a\n * client rendered it oddly.\n *\n * ## Why an iframe, and why sandboxed\n *\n * The mail is a whole document with its own `<body>` background, and rendering\n * that inside the console's DOM would both break the mail (the console's CSS\n * reaches it) and break the console (the mail's body styles reach the page). A\n * frame is the only honest preview.\n *\n * `sandbox=\"\"` — no scripts, no forms, no top-level navigation. These documents\n * come from the host's own renderer and carry no script, so this is less a\n * containment measure than a statement that the preview is INERT: a click on a\n * CTA inside a previewed mail must never navigate the operator anywhere, least\n * of all to a sample verification link.\n */\n\n/** The two widths the HTML view renders at. */\nexport type PreviewWidth = 'desktop' | 'mobile';\n\n/** Which of the three views is showing. */\nexport type PreviewTab = 'html' | 'text' | 'source';\n\n/** A phone. 390px is a common iPhone CSS width, and among the narrowest. */\nconst MOBILE_WIDTH = 390;\n\nfunction Monospace({ children, testId }: { children: string; testId: string }): JSX.Element {\n return (\n <Box\n component=\"pre\"\n data-testid={testId}\n sx={{\n m: 0,\n p: 2,\n borderRadius: 1.5,\n border: '1px solid',\n borderColor: 'divider',\n background: 'background.default',\n fontSize: 13,\n lineHeight: 1.6,\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n maxHeight: 720,\n overflow: 'auto',\n }}\n >\n {children}\n </Box>\n );\n}\n\nfunction HtmlFrame({\n detail,\n width,\n title,\n}: {\n detail: EmailPreviewDetail;\n width: PreviewWidth;\n title: string;\n}): JSX.Element {\n return (\n <Box\n sx={{\n display: 'flex',\n justifyContent: 'center',\n borderRadius: 1.5,\n border: '1px solid',\n borderColor: 'divider',\n overflow: 'hidden',\n }}\n >\n <Box\n component=\"iframe\"\n data-testid=\"email-preview-frame\"\n title={title}\n srcDoc={detail.html}\n sandbox=\"\"\n sx={{\n border: 0,\n width: width === 'mobile' ? MOBILE_WIDTH : '100%',\n height: 760,\n background: '#fff',\n }}\n />\n </Box>\n );\n}\n\ninterface MessageViewProps {\n detail: EmailPreviewDetail;\n copy: EmailPreviewScreenCopy;\n tab: PreviewTab;\n width: PreviewWidth;\n onTabChange: (tab: PreviewTab) => void;\n onWidthChange: (width: PreviewWidth) => void;\n}\n\nexport function MessageView(props: MessageViewProps): JSX.Element {\n const { detail, copy, tab, width, onTabChange, onWidthChange } = props;\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }} data-testid=\"email-preview-view\">\n <Box>\n <Text as=\"p\" size=\"xs\" color=\"secondary\">\n {copy.subjectLabel}\n </Text>\n <Text as=\"p\" size=\"md\" weight=\"medium\" data-testid=\"email-preview-subject\">\n {detail.subject}\n </Text>\n </Box>\n <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>\n <ToggleGroup\n dataTestId=\"email-preview-tabs\"\n exclusive\n value={tab}\n size=\"sm\"\n options={[\n { value: 'html', label: copy.tabHtml },\n { value: 'text', label: copy.tabText },\n { value: 'source', label: copy.tabSource },\n ]}\n onChange={(_event, value) => {\n if (value) onTabChange(value as PreviewTab);\n }}\n />\n {tab === 'html' ? (\n <ToggleGroup\n dataTestId=\"email-preview-width\"\n exclusive\n value={width}\n size=\"sm\"\n options={[\n { value: 'desktop', label: copy.widthDesktop },\n { value: 'mobile', label: copy.widthMobile },\n ]}\n onChange={(_event, value) => {\n if (value) onWidthChange(value as PreviewWidth);\n }}\n />\n ) : null}\n </Box>\n {tab === 'html' ? <HtmlFrame detail={detail} width={width} title={copy.frameTitle} /> : null}\n {tab === 'text' ? <Monospace testId=\"email-preview-text\">{detail.text}</Monospace> : null}\n {tab === 'source' ? <Monospace testId=\"email-preview-source\">{detail.html}</Monospace> : null}\n </Box>\n );\n}\n","import { useCallback, useMemo, useState, type JSX } from 'react';\n\nimport { Alert } from '@12-apps/ui/data-display/Alert';\nimport { Input } from '@12-apps/ui/form/Input';\nimport { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Heading } from '@12-apps/ui/typography/Heading';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewCoverage, EmailPreviewDetail, EmailPreviewIndex } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\nimport { Failure, useLoadable } from './loadable';\nimport { MessageList, matchesFilter } from './message-list';\nimport { MessageView, type PreviewTab, type PreviewWidth } from './message-view';\nimport { fetchEmailPreview, fetchEmailPreviewIndex } from './transport';\n\n/**\n * The operator screen over the `./email/previews` catalogue.\n *\n * ## What a host supplies, and what it does not\n *\n * `apiBase` — where the routes are mounted — and `copy`, by name. Nothing else:\n * the owners, the languages, the messages and their subjects all arrive from\n * the surface, because they are facts about the host's own mail that no prop\n * could usefully restate.\n *\n * ## The selection lives in the URL, without a router\n *\n * `?id=` and `?locale=`, read and written through `history.replaceState`. A\n * link to one mail in one language is the actual workflow this screen serves —\n * \"look at what the reset mail says now\" — and local state would make every\n * such conversation a set of instructions instead of a link.\n *\n * Deliberately NOT a router integration: this package cannot know whether a\n * host runs react-router, TanStack Router or a framework's own, and a screen\n * that imported one would be unmountable in the other two. `replaceState` is\n * the one API all of them are built on, and `replace` rather than `push` so\n * browsing twenty mails is not twenty back-button steps.\n */\n\nexport interface EmailPreviewScreenConfig {\n /** Where the routes are mounted, e.g. `/api/platform/email-previews`. */\n readonly apiBase: string;\n /** The screen's words. REQUIRED — see `./copy`. */\n readonly copy: EmailPreviewScreenCopy;\n}\n\n/** Read one search param without assuming a router owns the URL. */\nfunction searchParam(name: string): string | null {\n if (typeof window === 'undefined') return null;\n return new URLSearchParams(window.location.search).get(name);\n}\n\n/**\n * Patch the query string in place, keeping everything else about the URL.\n *\n * The HASH is carried over deliberately, and it is not defensive coding: a host\n * that routes on `location.hash` — the consumer harness does, and so does any\n * SPA served from a static file — would otherwise be navigated off this screen\n * by its own locale switch, because rebuilding the URL from `pathname` alone\n * silently drops the fragment that says which page this is.\n *\n * `replaceState` rather than `pushState` for the reason the screen exists:\n * browsing twenty messages is one place to come back from, not twenty\n * back-button steps.\n */\nfunction patchSearch(patch: Record<string, string>): void {\n if (typeof window === 'undefined') return;\n const next = new URLSearchParams(window.location.search);\n for (const [key, value] of Object.entries(patch)) next.set(key, value);\n const { pathname, hash } = window.location;\n window.history.replaceState({}, '', `${pathname}?${next.toString()}${hash}`);\n}\n\n/** The surface's honest report about what it cannot show. */\nfunction CoverageNotice({\n coverage,\n copy,\n}: {\n coverage: EmailPreviewCoverage;\n copy: EmailPreviewScreenCopy;\n}): JSX.Element | null {\n if (coverage.missing.length === 0 && coverage.orphan.length === 0) return null;\n return (\n <Alert severity=\"warning\" data-testid=\"email-preview-coverage\">\n <Text as=\"p\" size=\"sm\" weight=\"medium\">\n {copy.coverageTitle}\n </Text>\n {coverage.missing.length > 0 ? (\n <Text as=\"p\" size=\"sm\">\n {copy.missingSamples(coverage.missing.join(', '))}\n </Text>\n ) : null}\n {coverage.orphan.length > 0 ? (\n <Text as=\"p\" size=\"sm\">\n {copy.orphanSamples(coverage.orphan.join(', '))}\n </Text>\n ) : null}\n </Alert>\n );\n}\n\n/** The right-hand pane: the selected message, or an invitation to pick one. */\nfunction PreviewPane({\n apiBase,\n id,\n locale,\n copy,\n tab,\n width,\n onTabChange,\n onWidthChange,\n}: {\n apiBase: string;\n id: string | null;\n locale: string;\n copy: EmailPreviewScreenCopy;\n tab: PreviewTab;\n width: PreviewWidth;\n onTabChange: (tab: PreviewTab) => void;\n onWidthChange: (width: PreviewWidth) => void;\n}): JSX.Element {\n const load = useCallback(\n () =>\n id === null\n ? Promise.resolve(null as EmailPreviewDetail | null)\n : fetchEmailPreview(apiBase, id, locale),\n [apiBase, id, locale],\n );\n const detail = useLoadable(load);\n\n if (id === null) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-empty\">\n {copy.pickOne}\n </Text>\n );\n }\n if (detail.error !== null) {\n return <Failure message={detail.error} copy={copy} onRetry={detail.reload} />;\n }\n if (detail.data === null) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-detail-loading\">\n {copy.loading}\n </Text>\n );\n }\n return (\n <MessageView\n detail={detail.data}\n copy={copy}\n tab={tab}\n width={width}\n onTabChange={onTabChange}\n onWidthChange={onWidthChange}\n />\n );\n}\n\n/** The left column: the filter, and the rows under their owners. */\nfunction CatalogueColumn({\n index,\n copy,\n selectedId,\n onSelect,\n}: {\n index: EmailPreviewIndex;\n copy: EmailPreviewScreenCopy;\n selectedId: string | null;\n onSelect: (id: string) => void;\n}): JSX.Element {\n const [filter, setFilter] = useState('');\n const visible = useMemo(\n () => index.items.filter((row) => matchesFilter(row, filter)),\n [index.items, filter],\n );\n return (\n /*\n The list owns its OWN scroll rather than growing the page.\n\n A host's console is typically a fixed-viewport shell whose centre column\n scrolls, and a twenty-mail catalogue then scrolls the preview frame off\n the screen — the two things this screen exists to show side by side\n cannot both be on it. `sticky` keeps the list put while the frame is\n read; the height is the viewport minus the chrome above it, so the column\n ends where the window does rather than at an arbitrary pixel count.\n */\n <Box\n sx={{\n width: 320,\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n gap: 1.5,\n position: 'sticky',\n top: 0,\n maxHeight: 'calc(100dvh - 220px)',\n minHeight: 240,\n }}\n >\n <Input\n label={copy.searchLabel}\n placeholder={copy.searchPlaceholder}\n value={filter}\n data-testid=\"email-preview-filter\"\n onChange={(event) => setFilter(event.target.value)}\n fullWidth\n />\n {/* Only the ROWS scroll — the filter field stays reachable. */}\n <Box sx={{ overflowY: 'auto', flex: 1, pr: 0.5 }}>\n <MessageList rows={visible} selectedId={selectedId} copy={copy} onSelect={onSelect} />\n </Box>\n </Box>\n );\n}\n\n/** The catalogue and the preview, once the index has loaded. */\nfunction Browser({\n apiBase,\n index,\n copy,\n locale,\n selectedId,\n onPatch,\n}: {\n apiBase: string;\n index: EmailPreviewIndex;\n copy: EmailPreviewScreenCopy;\n locale: string;\n selectedId: string | null;\n onPatch: (patch: Record<string, string>) => void;\n}): JSX.Element {\n const [tab, setTab] = useState<PreviewTab>('html');\n const [width, setWidth] = useState<PreviewWidth>('desktop');\n\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <CoverageNotice coverage={index.coverage} copy={copy} />\n <ToggleGroup\n dataTestId=\"email-preview-locale\"\n exclusive\n value={locale}\n size=\"sm\"\n options={index.locales.map((tag) => ({ value: tag, label: tag }))}\n onChange={(_event, value) => {\n if (value) onPatch({ locale: String(value) });\n }}\n />\n <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>\n <CatalogueColumn\n index={index}\n copy={copy}\n selectedId={selectedId}\n onSelect={(id) => onPatch({ id })}\n />\n <Box sx={{ flex: 1, minWidth: 360 }}>\n <PreviewPane\n apiBase={apiBase}\n id={selectedId}\n locale={locale}\n copy={copy}\n tab={tab}\n width={width}\n onTabChange={setTab}\n onWidthChange={setWidth}\n />\n </Box>\n </Box>\n </Box>\n );\n}\n\n/**\n * Build the screen. One call, one config object — the shape every factory in\n * this estate has.\n */\nexport function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {\n page: () => JSX.Element;\n} {\n const { apiBase, copy } = config;\n\n function EmailPreviewsPage(): JSX.Element {\n // The VALUE is deliberately discarded: nothing reads the counter, and the\n // only thing it has to do is change, so React re-renders and the URL is\n // re-read below. It used to be a dependency of the catalogue fetch, which\n // is what made every row click refetch the list.\n const [, setUrlNonce] = useState(0);\n const locale = searchParam('locale') ?? '';\n const selectedId = searchParam('id');\n // The catalogue depends on the LANGUAGE and on nothing else. `urlNonce` is\n // deliberately absent: it counts every URL patch, selection included, and\n // including it here refetched the whole catalogue on each row click — for a\n // list whose contents cannot have changed. What the click actually needs is\n // a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does\n // that on its own.\n // Not `apiBase`: it comes from the FACTORY's config, not from this\n // component, so it is constant for the component's whole life and listing\n // it says this callback can change when it cannot.\n const load = useCallback(() => fetchEmailPreviewIndex(apiBase, locale), [locale]);\n const index = useLoadable(load, { keepPrevious: true });\n\n const patch = (next: Record<string, string>): void => {\n patchSearch(next);\n // `replaceState` does not notify React, so the screen re-reads the URL\n // through this counter rather than through a router's own subscription.\n setUrlNonce((n) => n + 1);\n };\n\n return (\n <Box data-testid=\"page-email-previews\" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <Box>\n <Heading level=\"h2\">{copy.title}</Heading>\n <Text as=\"p\" size=\"sm\" color=\"secondary\">\n {copy.description}\n </Text>\n </Box>\n {index.error !== null ? (\n <Failure message={index.error} copy={copy} onRetry={index.reload} />\n ) : null}\n {index.error === null && index.data === null ? (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-index-loading\">\n {copy.loading}\n </Text>\n ) : null}\n {index.data !== null ? (\n <Browser\n apiBase={apiBase}\n index={index.data}\n copy={copy}\n locale={index.data.locale}\n selectedId={selectedId}\n onPatch={patch}\n />\n ) : null}\n </Box>\n );\n }\n\n return { page: EmailPreviewsPage };\n}\n","import type { JSX } from 'react';\nimport { useEffect, useState } from 'react';\n\nimport { Alert } from '@12-apps/ui/data-display/Alert';\nimport { Button } from '@12-apps/ui/form/Button';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * The screen's async plumbing: one hook, and the state it cannot render itself.\n *\n * Its own module because it is the half with no opinion about e-mail. What is\n * left in `preview-screen.tsx` is composition — which columns, which panes,\n * what the URL says — and this is the machinery underneath all of it.\n *\n * `keepPrevious` is the load-bearing option and its docblock says why: a\n * consumer that renders children only while `data` is non-null gets those\n * children UNMOUNTED by a blanking reload, which silently discards whatever\n * state they held.\n */\n\n/** A load that can fail, in the two states a screen has to render. */\ninterface Loadable<T> {\n data: T | null;\n error: string | null;\n}\n\nexport function useLoadable<T>(\n load: () => Promise<T>,\n options: { keepPrevious?: boolean } = {},\n): Loadable<T> & { reload: () => void } {\n const [state, setState] = useState<Loadable<T>>({ data: null, error: null });\n const [nonce, setNonce] = useState(0);\n const { keepPrevious = false } = options;\n useEffect(() => {\n let live = true;\n // `keepPrevious` holds the last good answer on screen while the next one is\n // in flight, and it is not a nicety. The consumer of this hook renders its\n // children only while `data` is non-null, so blanking here UNMOUNTS them —\n // taking the filter text, the open tab and the chosen width with it. On a\n // fast connection the refetch lands before anyone notices; on a slow one\n // the operator watches what they just typed disappear.\n setState((previous) => (keepPrevious ? { ...previous, error: null } : { data: null, error: null }));\n load()\n .then((data) => live && setState({ data, error: null }))\n .catch(\n (error: unknown) =>\n live &&\n setState({ data: null, error: error instanceof Error ? error.message : String(error) }),\n );\n return () => {\n // A language switched twice in a second must not let the FIRST answer\n // land last — the screen would show a document the operator did not ask\n // for, with the toggle disagreeing.\n live = false;\n };\n // `keepPrevious` belongs here: it decides whether this effect blanks the\n // children, so a caller that flips it must get the new behaviour rather\n // than the one captured on first render.\n }, [load, nonce, keepPrevious]);\n return { ...state, reload: () => setNonce((n) => n + 1) };\n}\n\nexport function Failure({\n message,\n copy,\n onRetry,\n}: {\n message: string;\n copy: EmailPreviewScreenCopy;\n onRetry: () => void;\n}): JSX.Element {\n return (\n <Box data-testid=\"email-preview-error\" sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>\n <Alert severity=\"error\">\n <Text as=\"p\" size=\"sm\">{copy.loadError}</Text>\n <Text as=\"p\" size=\"sm\">{message}</Text>\n </Alert>\n <Button size=\"sm\" variant=\"outline\" onClick={onRetry}>\n {copy.retry}\n </Button>\n </Box>\n );\n}\n","import type { JSX } from 'react';\n\nimport { Chip } from '@12-apps/ui/data-display/Chip';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewRow } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * The catalogue, grouped by the PACKAGE that owns each message.\n *\n * Grouping by owner rather than by family is the whole answer to \"which parts\n * of this system send mail\": the section headers ARE that list, derived from\n * the rows the surface sent rather than written down anywhere, so a package\n * that starts sending mail appears the day it does.\n *\n * The family stays visible as a chip on each row, because it is the other\n * question an operator asks — and one owner can span two families.\n */\n\ninterface OwnerGroup {\n owner: string;\n rows: EmailPreviewRow[];\n}\n\n/** Group in FIRST-SEEN order, so the list does not reshuffle between renders. */\nfunction groupByOwner(rows: readonly EmailPreviewRow[]): OwnerGroup[] {\n const groups = new Map<string, EmailPreviewRow[]>();\n for (const row of rows) {\n const bucket = groups.get(row.owner);\n if (bucket) bucket.push(row);\n else groups.set(row.owner, [row]);\n }\n return [...groups].map(([owner, ownerRows]) => ({ owner, rows: ownerRows }));\n}\n\n/** Does this row match what was typed? Subject, key, owner and family all count. */\nexport function matchesFilter(row: EmailPreviewRow, filter: string): boolean {\n const needle = filter.trim().toLowerCase();\n if (needle === '') return true;\n return [row.subject, row.key, row.owner, row.family].some((field) =>\n field.toLowerCase().includes(needle),\n );\n}\n\nfunction MessageRow({\n row,\n selected,\n onSelect,\n}: {\n row: EmailPreviewRow;\n selected: boolean;\n onSelect: (id: string) => void;\n}): JSX.Element {\n return (\n <Box\n component=\"button\"\n type=\"button\"\n data-testid={`email-preview-row-${row.id}`}\n aria-current={selected}\n onClick={() => onSelect(row.id)}\n sx={{\n appearance: 'none',\n textAlign: 'left',\n width: '100%',\n cursor: 'pointer',\n border: '1px solid',\n borderColor: selected ? 'primary.main' : 'divider',\n background: selected ? 'action.selected' : 'background.paper',\n borderRadius: 1.5,\n p: 1.25,\n display: 'flex',\n flexDirection: 'column',\n gap: 0.5,\n }}\n >\n <Text as=\"span\" size=\"sm\" weight=\"medium\">\n {row.subject}\n </Text>\n <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>\n <Chip label={row.family} size=\"sm\" variant=\"outlined\" color=\"neutral\" />\n <Text as=\"span\" size=\"xs\" color=\"secondary\">\n {row.key}\n </Text>\n </Box>\n </Box>\n );\n}\n\nexport function MessageList({\n rows,\n selectedId,\n copy,\n onSelect,\n}: {\n rows: readonly EmailPreviewRow[];\n selectedId: string | null;\n copy: EmailPreviewScreenCopy;\n onSelect: (id: string) => void;\n}): JSX.Element {\n if (rows.length === 0) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-no-matches\">\n {copy.noMatches}\n </Text>\n );\n }\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {groupByOwner(rows).map((group) => (\n <Box\n key={group.owner}\n data-testid={`email-preview-owner-${group.owner}`}\n sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}\n >\n <Text as=\"p\" size=\"xs\" weight=\"medium\" color=\"secondary\">\n {group.owner}\n </Text>\n {group.rows.map((row) => (\n <MessageRow\n key={row.id}\n row={row}\n selected={row.id === selectedId}\n onSelect={onSelect}\n />\n ))}\n </Box>\n ))}\n </Box>\n );\n}\n","import type { EmailPreviewDetail, EmailPreviewIndex } from '../catalog';\n\n/**\n * How the screen reaches its own endpoints.\n *\n * Plain `fetch` rather than a data library: this package cannot know whether a\n * host runs react-query, SWR or nothing at all, and a screen that dragged one\n * in would put a second cache beside whichever the host already has. The two\n * calls here are a list and a document — neither needs invalidation, retries or\n * shared state, which is most of what a data library is for.\n *\n * Both unwrap the `{ data }` envelope the routes write, and both surface a\n * non-2xx as a thrown `Error` carrying whatever the surface said, so the screen\n * can show the operator the real refusal (an unknown locale, a 403 from the\n * host's own gate) rather than a generic failure.\n */\n\n/** The envelope every route in this package answers with. */\ninterface Envelope<T> {\n data?: T;\n error?: string;\n}\n\nasync function get<T>(url: string): Promise<T> {\n const response = await fetch(url, { headers: { Accept: 'application/json' } });\n let body: Envelope<T> = {};\n try {\n body = (await response.json()) as Envelope<T>;\n } catch {\n // A gate that refuses before the router runs may answer HTML, not JSON.\n // Falling through to the status line below is more useful than a parse\n // error naming a character offset.\n }\n if (!response.ok || body.data === undefined) {\n throw new Error(body.error ?? `The request failed (${response.status}).`);\n }\n return body.data;\n}\n\nconst withLocale = (base: string, locale: string): string =>\n `${base}?locale=${encodeURIComponent(locale)}`;\n\n/** The catalogue, with every subject rendered in `locale`. */\nexport function fetchEmailPreviewIndex(\n apiBase: string,\n locale: string,\n): Promise<EmailPreviewIndex> {\n return get<EmailPreviewIndex>(withLocale(apiBase, locale));\n}\n\n/** One rendered message. */\nexport function fetchEmailPreview(\n apiBase: string,\n id: string,\n locale: string,\n): Promise<EmailPreviewDetail> {\n return get<EmailPreviewDetail>(\n withLocale(`${apiBase}/${encodeURIComponent(id)}`, locale),\n );\n}\n"],"mappings":";;;;;AAEA,SAAS,mBAAmB;AAC5B,SAAS,WAAW;AACpB,SAAS,YAAY;AA6CjB,cAyEE,YAzEF;AAJJ,IAAM,eAAe;AAErB,SAAS,UAAU,EAAE,UAAU,OAAO,GAAsD;AAC1F,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAU;AAAA,MACV,eAAa;AAAA,MACb,IAAI;AAAA,QACF,GAAG;AAAA,QACH,GAAG;AAAA,QACH,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,YAAY;AAAA,QACZ,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,WAAW;AAAA,QACX,UAAU;AAAA,MACZ;AAAA,MAEC;AAAA;AAAA,EACH;AAEJ;AAvBS;AAyBT,SAAS,UAAU;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,SACE;AAAA,IAAC;AAAA;AAAA,MACC,IAAI;AAAA,QACF,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MAEA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,eAAY;AAAA,UACZ;AAAA,UACA,QAAQ,OAAO;AAAA,UACf,SAAQ;AAAA,UACR,IAAI;AAAA,YACF,QAAQ;AAAA,YACR,OAAO,UAAU,WAAW,eAAe;AAAA,YAC3C,QAAQ;AAAA,YACR,YAAY;AAAA,UACd;AAAA;AAAA,MACF;AAAA;AAAA,EACF;AAEJ;AAnCS;AA8CF,SAAS,YAAY,OAAsC;AAChE,QAAM,EAAE,QAAQ,MAAM,KAAK,OAAO,aAAa,cAAc,IAAI;AACjE,SACE,qBAAC,OAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,IAAI,GAAG,eAAY,sBAC3E;AAAA,yBAAC,OACC;AAAA,0BAAC,QAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAC1B,eAAK,cACR;AAAA,MACA,oBAAC,QAAK,IAAG,KAAI,MAAK,MAAK,QAAO,UAAS,eAAY,yBAChD,iBAAO,SACV;AAAA,OACF;AAAA,IACA,qBAAC,OAAI,IAAI,EAAE,SAAS,QAAQ,UAAU,QAAQ,KAAK,GAAG,YAAY,SAAS,GACzE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,YAAW;AAAA,UACX,WAAS;AAAA,UACT,OAAO;AAAA,UACP,MAAK;AAAA,UACL,SAAS;AAAA,YACP,EAAE,OAAO,QAAQ,OAAO,KAAK,QAAQ;AAAA,YACrC,EAAE,OAAO,QAAQ,OAAO,KAAK,QAAQ;AAAA,YACrC,EAAE,OAAO,UAAU,OAAO,KAAK,UAAU;AAAA,UAC3C;AAAA,UACA,UAAU,CAAC,QAAQ,UAAU;AAC3B,gBAAI,MAAO,aAAY,KAAmB;AAAA,UAC5C;AAAA;AAAA,MACF;AAAA,MACC,QAAQ,SACP;AAAA,QAAC;AAAA;AAAA,UACC,YAAW;AAAA,UACX,WAAS;AAAA,UACT,OAAO;AAAA,UACP,MAAK;AAAA,UACL,SAAS;AAAA,YACP,EAAE,OAAO,WAAW,OAAO,KAAK,aAAa;AAAA,YAC7C,EAAE,OAAO,UAAU,OAAO,KAAK,YAAY;AAAA,UAC7C;AAAA,UACA,UAAU,CAAC,QAAQ,UAAU;AAC3B,gBAAI,MAAO,eAAc,KAAqB;AAAA,UAChD;AAAA;AAAA,MACF,IACE;AAAA,OACN;AAAA,IACC,QAAQ,SAAS,oBAAC,aAAU,QAAgB,OAAc,OAAO,KAAK,YAAY,IAAK;AAAA,IACvF,QAAQ,SAAS,oBAAC,aAAU,QAAO,sBAAsB,iBAAO,MAAK,IAAe;AAAA,IACpF,QAAQ,WAAW,oBAAC,aAAU,QAAO,wBAAwB,iBAAO,MAAK,IAAe;AAAA,KAC3F;AAEJ;AAhDgB;;;ACtHhB,SAAS,aAAa,SAAS,YAAAA,iBAA0B;AAEzD,SAAS,SAAAC,cAAa;AACtB,SAAS,aAAa;AACtB,SAAS,eAAAC,oBAAmB;AAC5B,SAAS,OAAAC,YAAW;AACpB,SAAS,eAAe;AACxB,SAAS,QAAAC,aAAY;;;ACNrB,SAAS,WAAW,gBAAgB;AAEpC,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,OAAAC,YAAW;AACpB,SAAS,QAAAC,aAAY;AAsEf,SACE,OAAAC,MADF,QAAAC,aAAA;AA/CC,SAAS,YACd,MACA,UAAsC,CAAC,GACD;AACtC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAsB,EAAE,MAAM,MAAM,OAAO,KAAK,CAAC;AAC3E,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,CAAC;AACpC,QAAM,EAAE,eAAe,MAAM,IAAI;AACjC,YAAU,MAAM;AACd,QAAI,OAAO;AAOX,aAAS,CAAC,aAAc,eAAe,EAAE,GAAG,UAAU,OAAO,KAAK,IAAI,EAAE,MAAM,MAAM,OAAO,KAAK,CAAE;AAClG,SAAK,EACF,KAAK,CAAC,SAAS,QAAQ,SAAS,EAAE,MAAM,OAAO,KAAK,CAAC,CAAC,EACtD;AAAA,MACC,CAAC,UACC,QACA,SAAS,EAAE,MAAM,MAAM,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,CAAC;AAAA,IAC1F;AACF,WAAO,MAAM;AAIX,aAAO;AAAA,IACT;AAAA,EAIF,GAAG,CAAC,MAAM,OAAO,YAAY,CAAC;AAC9B,SAAO,EAAE,GAAG,OAAO,QAAQ,6BAAM,SAAS,CAAC,MAAM,IAAI,CAAC,GAA3B,UAA6B;AAC1D;AAlCgB;AAoCT,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,SACE,gBAAAA,MAACC,MAAA,EAAI,eAAY,uBAAsB,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,GAAG,YAAY,aAAa,GACtH;AAAA,oBAAAD,MAAC,SAAM,UAAS,SACd;AAAA,sBAAAD,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAM,eAAK,WAAU;AAAA,MACvC,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAM,mBAAQ;AAAA,OAClC;AAAA,IACA,gBAAAH,KAAC,UAAO,MAAK,MAAK,SAAQ,WAAU,SAAS,SAC1C,eAAK,OACR;AAAA,KACF;AAEJ;AApBgB;;;AC/DhB,SAAS,YAAY;AACrB,SAAS,OAAAI,YAAW;AACpB,SAAS,QAAAC,aAAY;AA0Ef,gBAAAC,MAGA,QAAAC,aAHA;AAlDN,SAAS,aAAa,MAAgD;AACpE,QAAM,SAAS,oBAAI,IAA+B;AAClD,aAAW,OAAO,MAAM;AACtB,UAAM,SAAS,OAAO,IAAI,IAAI,KAAK;AACnC,QAAI,OAAQ,QAAO,KAAK,GAAG;AAAA,QACtB,QAAO,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC;AAAA,EAClC;AACA,SAAO,CAAC,GAAG,MAAM,EAAE,IAAI,CAAC,CAAC,OAAO,SAAS,OAAO,EAAE,OAAO,MAAM,UAAU,EAAE;AAC7E;AARS;AAWF,SAAS,cAAc,KAAsB,QAAyB;AAC3E,QAAM,SAAS,OAAO,KAAK,EAAE,YAAY;AACzC,MAAI,WAAW,GAAI,QAAO;AAC1B,SAAO,CAAC,IAAI,SAAS,IAAI,KAAK,IAAI,OAAO,IAAI,MAAM,EAAE;AAAA,IAAK,CAAC,UACzD,MAAM,YAAY,EAAE,SAAS,MAAM;AAAA,EACrC;AACF;AANgB;AAQhB,SAAS,WAAW;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,SACE,gBAAAA;AAAA,IAACC;AAAA,IAAA;AAAA,MACC,WAAU;AAAA,MACV,MAAK;AAAA,MACL,eAAa,qBAAqB,IAAI,EAAE;AAAA,MACxC,gBAAc;AAAA,MACd,SAAS,MAAM,SAAS,IAAI,EAAE;AAAA,MAC9B,IAAI;AAAA,QACF,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,aAAa,WAAW,iBAAiB;AAAA,QACzC,YAAY,WAAW,oBAAoB;AAAA,QAC3C,cAAc;AAAA,QACd,GAAG;AAAA,QACH,SAAS;AAAA,QACT,eAAe;AAAA,QACf,KAAK;AAAA,MACP;AAAA,MAEA;AAAA,wBAAAF,KAACG,OAAA,EAAK,IAAG,QAAO,MAAK,MAAK,QAAO,UAC9B,cAAI,SACP;AAAA,QACA,gBAAAF,MAACC,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,YAAY,UAAU,KAAK,KAAK,GAC1D;AAAA,0BAAAF,KAAC,QAAK,OAAO,IAAI,QAAQ,MAAK,MAAK,SAAQ,YAAW,OAAM,WAAU;AAAA,UACtE,gBAAAA,KAACG,OAAA,EAAK,IAAG,QAAO,MAAK,MAAK,OAAM,aAC7B,cAAI,KACP;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;AA1CS;AA4CF,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAKgB;AACd,MAAI,KAAK,WAAW,GAAG;AACrB,WACE,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,4BAClD,eAAK,WACR;AAAA,EAEJ;AACA,SACE,gBAAAH,KAACE,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,EAAE,GACzD,uBAAa,IAAI,EAAE,IAAI,CAAC,UACvB,gBAAAD;AAAA,IAACC;AAAA,IAAA;AAAA,MAEC,eAAa,uBAAuB,MAAM,KAAK;AAAA,MAC/C,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,KAAK;AAAA,MAE1D;AAAA,wBAAAF,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,QAAO,UAAS,OAAM,aAC1C,gBAAM,OACT;AAAA,QACC,MAAM,KAAK,IAAI,CAAC,QACf,gBAAAH;AAAA,UAAC;AAAA;AAAA,YAEC;AAAA,YACA,UAAU,IAAI,OAAO;AAAA,YACrB;AAAA;AAAA,UAHK,IAAI;AAAA,QAIX,CACD;AAAA;AAAA;AAAA,IAdI,MAAM;AAAA,EAeb,CACD,GACH;AAEJ;AAzCgB;;;ACpEhB,eAAe,IAAO,KAAyB;AAC7C,QAAM,WAAW,MAAM,MAAM,KAAK,EAAE,SAAS,EAAE,QAAQ,mBAAmB,EAAE,CAAC;AAC7E,MAAI,OAAoB,CAAC;AACzB,MAAI;AACF,WAAQ,MAAM,SAAS,KAAK;AAAA,EAC9B,QAAQ;AAAA,EAIR;AACA,MAAI,CAAC,SAAS,MAAM,KAAK,SAAS,QAAW;AAC3C,UAAM,IAAI,MAAM,KAAK,SAAS,uBAAuB,SAAS,MAAM,IAAI;AAAA,EAC1E;AACA,SAAO,KAAK;AACd;AAde;AAgBf,IAAM,aAAa,wBAAC,MAAc,WAChC,GAAG,IAAI,WAAW,mBAAmB,MAAM,CAAC,IAD3B;AAIZ,SAAS,uBACd,SACA,QAC4B;AAC5B,SAAO,IAAuB,WAAW,SAAS,MAAM,CAAC;AAC3D;AALgB;AAQT,SAAS,kBACd,SACA,IACA,QAC6B;AAC7B,SAAO;AAAA,IACL,WAAW,GAAG,OAAO,IAAI,mBAAmB,EAAE,CAAC,IAAI,MAAM;AAAA,EAC3D;AACF;AARgB;;;AHkCZ,SACE,OAAAI,MADF,QAAAC,aAAA;AApCJ,SAAS,YAAY,MAA6B;AAChD,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAO,IAAI,gBAAgB,OAAO,SAAS,MAAM,EAAE,IAAI,IAAI;AAC7D;AAHS;AAkBT,SAAS,YAAY,OAAqC;AACxD,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,OAAO,IAAI,gBAAgB,OAAO,SAAS,MAAM;AACvD,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,EAAG,MAAK,IAAI,KAAK,KAAK;AACrE,QAAM,EAAE,UAAU,KAAK,IAAI,OAAO;AAClC,SAAO,QAAQ,aAAa,CAAC,GAAG,IAAI,GAAG,QAAQ,IAAI,KAAK,SAAS,CAAC,GAAG,IAAI,EAAE;AAC7E;AANS;AAST,SAAS,eAAe;AAAA,EACtB;AAAA,EACA;AACF,GAGuB;AACrB,MAAI,SAAS,QAAQ,WAAW,KAAK,SAAS,OAAO,WAAW,EAAG,QAAO;AAC1E,SACE,gBAAAA,MAACC,QAAA,EAAM,UAAS,WAAU,eAAY,0BACpC;AAAA,oBAAAF,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,QAAO,UAC3B,eAAK,eACR;AAAA,IACC,SAAS,QAAQ,SAAS,IACzB,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MACf,eAAK,eAAe,SAAS,QAAQ,KAAK,IAAI,CAAC,GAClD,IACE;AAAA,IACH,SAAS,OAAO,SAAS,IACxB,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MACf,eAAK,cAAc,SAAS,OAAO,KAAK,IAAI,CAAC,GAChD,IACE;AAAA,KACN;AAEJ;AAzBS;AA4BT,SAAS,YAAY;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASgB;AACd,QAAM,OAAO;AAAA,IACX,MACE,OAAO,OACH,QAAQ,QAAQ,IAAiC,IACjD,kBAAkB,SAAS,IAAI,MAAM;AAAA,IAC3C,CAAC,SAAS,IAAI,MAAM;AAAA,EACtB;AACA,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,OAAO,MAAM;AACf,WACE,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,uBAClD,eAAK,SACR;AAAA,EAEJ;AACA,MAAI,OAAO,UAAU,MAAM;AACzB,WAAO,gBAAAH,KAAC,WAAQ,SAAS,OAAO,OAAO,MAAY,SAAS,OAAO,QAAQ;AAAA,EAC7E;AACA,MAAI,OAAO,SAAS,MAAM;AACxB,WACE,gBAAAA,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,gCAClD,eAAK,SACR;AAAA,EAEJ;AACA,SACE,gBAAAH;AAAA,IAAC;AAAA;AAAA,MACC,QAAQ,OAAO;AAAA,MACf;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;AAvDS;AA0DT,SAAS,gBAAgB;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAKgB;AACd,QAAM,CAAC,QAAQ,SAAS,IAAII,UAAS,EAAE;AACvC,QAAM,UAAU;AAAA,IACd,MAAM,MAAM,MAAM,OAAO,CAAC,QAAQ,cAAc,KAAK,MAAM,CAAC;AAAA,IAC5D,CAAC,MAAM,OAAO,MAAM;AAAA,EACtB;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWE,gBAAAH;AAAA,MAACI;AAAA,MAAA;AAAA,QACC,IAAI;AAAA,UACF,OAAO;AAAA,UACP,YAAY;AAAA,UACZ,SAAS;AAAA,UACT,eAAe;AAAA,UACf,KAAK;AAAA,UACL,UAAU;AAAA,UACV,KAAK;AAAA,UACL,WAAW;AAAA,UACX,WAAW;AAAA,QACb;AAAA,QAEA;AAAA,0BAAAL;AAAA,YAAC;AAAA;AAAA,cACC,OAAO,KAAK;AAAA,cACZ,aAAa,KAAK;AAAA,cAClB,OAAO;AAAA,cACP,eAAY;AAAA,cACZ,UAAU,CAAC,UAAU,UAAU,MAAM,OAAO,KAAK;AAAA,cACjD,WAAS;AAAA;AAAA,UACX;AAAA,UAEA,gBAAAA,KAACK,MAAA,EAAI,IAAI,EAAE,WAAW,QAAQ,MAAM,GAAG,IAAI,IAAI,GAC7C,0BAAAL,KAAC,eAAY,MAAM,SAAS,YAAwB,MAAY,UAAoB,GACtF;AAAA;AAAA;AAAA,IACF;AAAA;AAEJ;AAtDS;AAyDT,SAAS,QAAQ;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAOgB;AACd,QAAM,CAAC,KAAK,MAAM,IAAII,UAAqB,MAAM;AACjD,QAAM,CAAC,OAAO,QAAQ,IAAIA,UAAuB,SAAS;AAE1D,SACE,gBAAAH,MAACI,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,EAAE,GAC1D;AAAA,oBAAAL,KAAC,kBAAe,UAAU,MAAM,UAAU,MAAY;AAAA,IACtD,gBAAAA;AAAA,MAACM;AAAA,MAAA;AAAA,QACC,YAAW;AAAA,QACX,WAAS;AAAA,QACT,OAAO;AAAA,QACP,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,IAAI,CAAC,SAAS,EAAE,OAAO,KAAK,OAAO,IAAI,EAAE;AAAA,QAChE,UAAU,CAAC,QAAQ,UAAU;AAC3B,cAAI,MAAO,SAAQ,EAAE,QAAQ,OAAO,KAAK,EAAE,CAAC;AAAA,QAC9C;AAAA;AAAA,IACF;AAAA,IACA,gBAAAL,MAACI,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,KAAK,GAAG,YAAY,cAAc,UAAU,OAAO,GAC7E;AAAA,sBAAAL;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA;AAAA,UACA;AAAA,UACA,UAAU,CAAC,OAAO,QAAQ,EAAE,GAAG,CAAC;AAAA;AAAA,MAClC;AAAA,MACA,gBAAAA,KAACK,MAAA,EAAI,IAAI,EAAE,MAAM,GAAG,UAAU,IAAI,GAChC,0BAAAL;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,IAAI;AAAA,UACJ;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,aAAa;AAAA,UACb,eAAe;AAAA;AAAA,MACjB,GACF;AAAA,OACF;AAAA,KACF;AAEJ;AArDS;AA2DF,SAAS,yBAAyB,QAEvC;AACA,QAAM,EAAE,SAAS,KAAK,IAAI;AAE1B,WAAS,oBAAiC;AAKxC,UAAM,CAAC,EAAE,WAAW,IAAII,UAAS,CAAC;AAClC,UAAM,SAAS,YAAY,QAAQ,KAAK;AACxC,UAAM,aAAa,YAAY,IAAI;AAUnC,UAAM,OAAO,YAAY,MAAM,uBAAuB,SAAS,MAAM,GAAG,CAAC,MAAM,CAAC;AAChF,UAAM,QAAQ,YAAY,MAAM,EAAE,cAAc,KAAK,CAAC;AAEtD,UAAM,QAAQ,wBAAC,SAAuC;AACpD,kBAAY,IAAI;AAGhB,kBAAY,CAAC,MAAM,IAAI,CAAC;AAAA,IAC1B,GALc;AAOd,WACE,gBAAAH,MAACI,MAAA,EAAI,eAAY,uBAAsB,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,EAAE,GAC5F;AAAA,sBAAAJ,MAACI,MAAA,EACC;AAAA,wBAAAL,KAAC,WAAQ,OAAM,MAAM,eAAK,OAAM;AAAA,QAChC,gBAAAA,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAC1B,eAAK,aACR;AAAA,SACF;AAAA,MACC,MAAM,UAAU,OACf,gBAAAH,KAAC,WAAQ,SAAS,MAAM,OAAO,MAAY,SAAS,MAAM,QAAQ,IAChE;AAAA,MACH,MAAM,UAAU,QAAQ,MAAM,SAAS,OACtC,gBAAAA,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,+BAClD,eAAK,SACR,IACE;AAAA,MACH,MAAM,SAAS,OACd,gBAAAH;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,OAAO,MAAM;AAAA,UACb;AAAA,UACA,QAAQ,MAAM,KAAK;AAAA,UACnB;AAAA,UACA,SAAS;AAAA;AAAA,MACX,IACE;AAAA,OACN;AAAA,EAEJ;AAvDS;AAyDT,SAAO,EAAE,MAAM,kBAAkB;AACnC;AA/DgB;","names":["useState","Alert","ToggleGroup","Box","Text","Box","Text","jsx","jsxs","Box","Text","Box","Text","jsx","jsxs","Box","Text","jsx","jsxs","Alert","Text","useState","Box","ToggleGroup"]}
|
|
@@ -181,10 +181,8 @@ function LiveSection({
|
|
|
181
181
|
useEffect(() => {
|
|
182
182
|
if (active && liveCount > 0) seen?.mark(activities);
|
|
183
183
|
}, [active, liveCount, activities, seen]);
|
|
184
|
-
if (liveCount === 0) return /* @__PURE__ */ jsx2(Fragment2, { children: children?.(0) });
|
|
185
184
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
186
|
-
|
|
187
|
-
/* @__PURE__ */ jsxs2(
|
|
185
|
+
liveCount === 0 ? null : /* @__PURE__ */ jsxs2(
|
|
188
186
|
Box2,
|
|
189
187
|
{
|
|
190
188
|
component: "section",
|
|
@@ -228,4 +226,4 @@ export {
|
|
|
228
226
|
relativeTime,
|
|
229
227
|
LiveSection
|
|
230
228
|
};
|
|
231
|
-
//# sourceMappingURL=chunk-
|
|
229
|
+
//# sourceMappingURL=chunk-MPUOVQVX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/relative-time.ts","../src/react/live-section.tsx","../src/react/live-card.tsx"],"sourcesContent":["import type { NotificationMessages } from '../messages';\n\n/**\n * \"há 5 min\"-style relative timestamp, falling back to an absolute date for\n * anything older than a week. Every word comes from the messages table, so a\n * host in another locale changes the copy and the locale together.\n *\n * `now` is a parameter rather than a read, and the live section is why. A\n * relative phrase is only true for the instant it was computed, so something\n * has to CAUSE the render that recomputes it — and the live entries' own data\n * cannot: a host backed by react-query gets the previous object back whenever a\n * poll is deep-equal (`structuralSharing`, on by default), which within one\n * stage it always is. The section therefore ticks a clock and hands it down.\n * Defaulted, so every existing caller reads the wall clock exactly as before.\n */\nexport function relativeTime(\n iso: string,\n messages: NotificationMessages,\n now: number = Date.now(),\n): string {\n const elapsedMs = now - new Date(iso).getTime();\n const minutes = Math.round(elapsedMs / 60_000);\n if (minutes < 1) return messages.justNow;\n if (minutes < 60) return messages.minutesAgo(minutes);\n const hours = Math.round(minutes / 60);\n if (hours < 24) return messages.hoursAgo(hours);\n const days = Math.round(hours / 24);\n if (days < 7) return messages.daysAgo(days);\n return new Date(iso).toLocaleDateString(messages.dateLocale);\n}\n","/**\n * The pinned block at the top of the panel: everything that is happening NOW,\n * above everything that has already happened.\n *\n * ## Why it is here and not a second surface\n *\n * The notification centre is where a person goes to find out what they missed.\n * Splitting \"happening\" into its own bell would make them check two places to\n * answer one question, and the half they would stop checking is the one that\n * only has something in it occasionally — which is this one. Above the list,\n * inside the same drawer, it is on the path they already walk.\n *\n * ## What it deliberately does NOT do\n *\n * - It does not mark anything READ. A live entry counts on the bell, but as\n * itself rather than as unread — the tone, not the number, is what says\n * whether it is news. (This once read \"it does not touch `unread`\", on the\n * argument that counting it would put a number on the bell no amount of\n * reading can clear. The argument stands; the tone is what answers it.)\n * - It renders no heading, no empty state and no reserved space when there is\n * nothing live — but it still renders its SLOT, so the inbox below keeps its\n * position and is not torn down and rebuilt every time a subject starts or\n * finishes.\n * - It does not fetch. `useActivities` is the host's, and `active` tells it\n * whether anyone is looking.\n */\nimport { useEffect, useId, useState, type JSX, type ReactNode } from 'react';\n\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\n\nimport { LiveActivityCard } from './live-card';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { LiveSeenStore } from './live-seen';\n\n/**\n * How often the section re-reads the clock.\n *\n * Every minute, because the timestamps under the cards are in minutes and a\n * tick that cannot change what is on screen is a wasted render — which is why\n * it is gated on there being something to tick as well as on the panel being\n * open. An open panel with nothing live schedules nothing at all; the earlier\n * gate was `active` alone, and it re-rendered a section that renders `null`\n * once a minute for as long as somebody left the inbox open.\n */\nconst TICK_MS = 60_000;\n\n/**\n * The current minute, re-read on a timer while there is something to tick.\n *\n * The caller passes `active && there are activities` — see {@link TICK_MS} for\n * why both halves are in it.\n */\nfunction useMinuteTick(active: boolean): number {\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n if (!active) return;\n // Re-read once on becoming active too: a panel reopened after ten minutes\n // would otherwise show the minute it was closed at until the first tick.\n setNow(Date.now());\n const timer = setInterval(() => setNow(Date.now()), TICK_MS);\n return () => clearInterval(timer);\n }, [active]);\n return now;\n}\n\nexport interface LiveSectionProps {\n config: LiveActivitiesConfig;\n messages: NotificationMessages;\n /** Whether the panel is open — passed straight through to the host's hook. */\n active: boolean;\n /**\n * Follow a card's link.\n *\n * Optional, and the panel omits it for a host with no router: a card that\n * cannot go anywhere renders as text rather than as a named control that\n * does nothing.\n */\n onOpen?: (activity: LiveActivity) => void;\n /**\n * The rest of the panel, given how many entries are live.\n *\n * A render prop rather than a sibling, because the count is knowable only\n * where the host's hook is CALLED, and it cannot be called anywhere else:\n * `live` is optional on the panel, so reading it there would mean calling a\n * hook conditionally — the failure React reports as a crash in some unrelated\n * component.\n *\n * The inbox needs the number for exactly one decision, and it is the decision\n * this section exists to inform: whether \"no notifications\" is true. A live\n * entry IS a notification, so a panel showing one under that sentence is\n * contradicting itself.\n */\n children?: (liveCount: number) => ReactNode;\n /**\n * Where \"the reader has seen these\" is recorded, for the bell to read.\n *\n * Written HERE because this is the component that puts them on screen, and\n * being on screen is what seen means. Optional so the section stays usable by\n * a host that mounts it outside the panel.\n */\n seen?: LiveSeenStore;\n}\n\n\n\n/**\n * ## Why this always renders its slot, even with nothing live\n *\n * React reconciles a fragment's children POSITIONALLY. The section and the\n * inbox are siblings in one fragment, and the empty branch used to render the\n * inbox ALONE — one child rather than two — so the inbox moved to a position\n * previously held by a different element type, which React handles by\n * unmounting the old subtree and mounting a new one. Every `NotificationRow`\n * would be torn down and rebuilt the moment a pedido started or finished,\n * throwing keyboard focus to `<body>` inside a focus-trapped drawer, for a\n * reader who was only scrolling their inbox.\n *\n * So the empty case renders `null` INTO the slot rather than returning early.\n * Pinned by comparing the row's DOM NODE across the transition: a test on the\n * test id alone passes either way, because a remounted row has the same id.\n */\nexport function LiveSection({\n config,\n messages,\n active,\n onOpen,\n children,\n seen,\n}: LiveSectionProps): JSX.Element {\n // Unconditional, because it is a hook. `active` is how it is told nobody is\n // looking — the same arrangement `useSignal` has one seam over.\n const activities = config.useActivities({ active });\n const now = useMinuteTick(active && activities.length > 0);\n // Per MOUNT, not per module: `LiveSection` is exported, and a host with a\n // desktop and a mobile panel would otherwise emit one id twice and have both\n // regions resolve their label to whichever came first.\n const headingId = useId();\n\n const liveCount = activities.length;\n\n // Only while somebody is looking. The panel keeps this mounted through the\n // closing transition, and marking there would swallow an update that arrived\n // in the frames after the reader turned away.\n useEffect(() => {\n if (active && liveCount > 0) seen?.mark(activities);\n }, [active, liveCount, activities, seen]);\n\n return (\n <>\n {/* A NAMED region, and always a SLOT — see the docblock above. */}\n {liveCount === 0 ? null : (\n <Box\n component=\"section\"\n aria-labelledby={headingId}\n data-testid=\"live-activities\"\n sx={{ pb: 1.5 }}\n >\n {/*\n A SPAN, not a heading. `aria-labelledby` names the region perfectly well\n from one, and an `<h2>` here would sit under the drawer's own `<h6>`\n title and ABOVE the inbox's `<h3>` empty state — an outline in which the\n inbox's states read as part of the live block, which is the opposite of\n what the two blocks are.\n */}\n <Text\n id={headingId}\n variant=\"caption\"\n size=\"xs\"\n color=\"secondary\"\n weight=\"semibold\"\n as=\"span\"\n >\n {config.messages.sectionTitle}\n </Text>\n <Box sx={{ pt: 0.75 }}>\n {activities.map((activity) => (\n <LiveActivityCard\n key={activity.id}\n activity={activity}\n messages={messages}\n live={config.messages}\n now={now}\n {...(onOpen ? { onOpen } : {})}\n {...(config.renderIcon ? { renderIcon: config.renderIcon } : {})}\n />\n ))}\n </Box>\n </Box>\n )}\n {children?.(liveCount)}\n </>\n );\n}\n","/**\n * ONE pinned live entry: a mark, what is happening, its lane, and when it last\n * moved.\n *\n * Visually a WASH rather than a fill — a tinted card with a brand-tinted border\n * — for the reason the inbox's unread row uses the same treatment: this sits at\n * the top of a list of other people's news, and a saturated block there\n * out-shouts everything it is supposed to be introducing.\n *\n * ## The card is a DIV, and the button is inside it\n *\n * The obvious shape — one `<button>` wrapping the whole card — is not\n * available, because `Stepper` draws every stop as a real `<button>`\n * (`@12-apps/ui`'s `StepButton` is `styled(Button)`), and `clickable={false}`\n * only sets `pointer-events: none`. A button inside a button is invalid HTML:\n * the parser auto-closes the outer one at the first nested one, so any host\n * that server-renders the panel open hydrates against a tree the browser\n * rewrote, and every adopter's dev console carries a React error besides.\n *\n * `aria-hidden` and `inert` on the lane fix the tab stops and the accessible\n * name — they do NOT fix the nesting, and an earlier draft of this file claimed\n * they did. So the tap target is the TEXT block, and the lane and the timestamp\n * are its siblings: valid markup, and a target that still covers everything a\n * reader would aim at.\n */\nimport { useId, type JSX, type ReactNode } from 'react';\n\nimport { Stepper } from '@12-apps/ui/data-display/Stepper';\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 { liveActivityLane, type LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\n\nimport type { LiveActivitiesConfig, LiveActivityMessages } from './live-config';\nimport { relativeTime } from './relative-time';\n\nconst cardSx = {\n // `relative`, so the button below can stretch a hit area over the whole card\n // — see `targetSx`.\n position: 'relative',\n border: '1px solid',\n borderColor: (t: Theme) => alpha(t.palette.primary.main, 0.35),\n bgcolor: (t: Theme) => alpha(t.palette.primary.main, 0.06),\n borderRadius: 1.5,\n p: 1.25,\n mb: 1,\n} as const;\n\n/** The text block: the mark, the heading and the sentence under it. */\nconst targetSx = {\n display: 'flex',\n alignItems: 'center',\n gap: 1,\n width: '100%',\n textAlign: 'left',\n font: 'inherit',\n color: 'inherit',\n border: 'none',\n background: 'none',\n p: 0,\n} as const;\n\n/**\n * The button, stretched over the WHOLE card.\n *\n * Taking the lane out of the link fixed the markup and left the card looking\n * like one target while only its top half was one — the lane is the most\n * visually distinctive part of it, and aiming at the obvious thing did nothing.\n *\n * A stretched pseudo-element is the remedy that keeps the structure: the\n * `<button>` stays a sibling of the lane in the tree, so nothing nests, and its\n * `::after` covers the card. All three declarations are load-bearing — a\n * pseudo-element with no `content` generates no box at all, and an absolutely\n * positioned box with auto offsets is 0×0.\n *\n * ## What actually lets a click on the LANE reach it\n *\n * Not paint order. `@12-apps/ui` gives each `StepItem` `position: relative`, and\n * this overlay is positioned too — so the two sit in the SAME painting layer\n * (positioned, `z-index: auto`), where tree order decides, and the lane comes\n * after the button. Every stop, and its label, therefore sits over this\n * overlay. `clickable={false}` does not save it either: the package puts\n * `pointer-events: none` on the step CIRCLE and not on the label beside it.\n *\n * It is `inert` on {@link ActivityLane} that does it: an inert subtree is\n * skipped by hit-testing, so a click on a stop falls through to the overlay\n * underneath. That makes the attribute load-bearing for the TARGET as well as\n * for the tab order it was added for — remove it and the lane silently swallows\n * clicks again, which is why the two are pinned by one test.\n */\nconst stretchedSx = {\n ...targetSx,\n cursor: 'pointer',\n '&::after': { content: '\"\"', position: 'absolute', inset: 0 },\n} as const;\n\n/**\n * Make a four-stop lane fit the panel.\n *\n * The drawer is 400px on a desktop and the full viewport on a phone, so the\n * narrow case is ~320px of card minus its padding. `Stepper` renders its labels\n * at `body2` for every size but `sm` and reserves 24px of connector plus 8px of\n * margin on each side, which is more row than four short words have — measured\n * on a 320px viewport, the last stop hung off the edge and the DRAWER scrolled\n * sideways.\n *\n * Three overrides, each buying back a specific number of pixels: 11px labels, a\n * step column allowed to shrink below the package's 44px floor (so the row's\n * min-content width is the longest WORD rather than the longest phrase), and\n * thinner connectors. `overflow: hidden` is the backstop and not the mechanism\n * — a locale with longer words than any of this anticipates clips its own card\n * instead of making the panel scroll.\n */\nconst laneSx = {\n pt: 1.25,\n px: 0.5,\n overflow: 'hidden',\n '& .MuiTypography-root': { fontSize: 11, lineHeight: 1.25 },\n '& [data-testid^=\"stepper-step-content-\"]': { minWidth: 0 },\n '& [data-testid^=\"stepper-connector-\"]': { minWidth: 6, mx: 0.75 },\n} as const;\n\n/**\n * The lane, or nothing.\n *\n * `aria-hidden` AND `inert`, and each earns its place twice over. The stops are\n * real buttons, so leaving four focusable, named controls per entry in front of\n * an inbox would cost a keyboard user the list they opened the panel for —\n * that is what the pair was added for. `inert` then turns out to be what makes\n * the card's own hit area work as well, because an inert subtree is skipped by\n * hit-testing: see {@link stretchedSx}.\n *\n * Nothing is lost by hiding it — the stop the subject is at is already the\n * card's heading, and the row of dots restates it visually.\n */\nfunction ActivityLane({ activity }: { activity: LiveActivity }): JSX.Element | null {\n const lane = liveActivityLane(activity);\n if (lane === null) return null;\n return (\n <Box sx={laneSx} aria-hidden inert>\n <Stepper\n steps={lane.steps.map((step) => ({ id: step.id, label: step.label }))}\n activeId={lane.activeStepId}\n completed={new Set(lane.completed)}\n orientation=\"horizontal\"\n size=\"xs\"\n clickable={false}\n data-testid={`live-activity-steps-${activity.id}`}\n />\n </Box>\n );\n}\n\n/** The card's props. Not part of the package's surface — see `./index`. */\ninterface LiveActivityCardProps {\n activity: LiveActivity;\n messages: NotificationMessages;\n live: LiveActivityMessages;\n renderIcon?: LiveActivitiesConfig['renderIcon'];\n /** The clock this render reads, so the \"last moved\" line can be ticked. */\n now: number;\n /**\n * Follow the card's link.\n *\n * Absent — as it is for a host with no router — renders the text as text. A\n * named, focusable control that does nothing is worse than no control.\n */\n onOpen?: (activity: LiveActivity) => void;\n}\n\n/**\n * The mark on the left, when the host draws one.\n *\n * PRESENTATIONAL ONLY. It is rendered inside the card's `<button>` and inside\n * an `aria-hidden` wrapper, so a host returning anything focusable — an\n * icon-button, a link — puts a button inside a button (invalid HTML, and the\n * defect this card was restructured to remove) and hides a focusable node from\n * the accessibility tree. An icon, an emoji, an `<svg>`: yes. A control: no.\n */\nfunction ActivityIcon({ icon }: { icon: ReactNode }): JSX.Element | null {\n if (icon === undefined || icon === null) return null;\n return (\n <Box aria-hidden sx={{ display: 'flex', flex: '0 0 auto', color: 'primary.main' }}>\n {icon}\n </Box>\n );\n}\n\n/** The mark, the heading and the line under it. */\nfunction ActivityTarget({\n activity,\n renderIcon,\n bodyId,\n}: Pick<LiveActivityCardProps, 'activity' | 'renderIcon'> & {\n /** Ties the sentence to the button, so a label does not swallow it. */\n bodyId: string;\n}): JSX.Element {\n return (\n <>\n <ActivityIcon icon={renderIcon?.(activity)} />\n {/* A COLUMN, not a bare block: `Text` sets no `display`, so two adjacent\n spans in an ordinary div run together on one line with not even a\n space between them — which is how the heading and the sentence under\n it ended up as one word in an earlier draft. `row.tsx` gets this right\n the same way. */}\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 0 }}>\n {/*\n The live region is THIS LINE and nothing else. The card also carries a\n relative timestamp that moves every minute for as long as the subject\n lasts, and announcing that is a polite interruption per minute for\n news the reader did not ask to be read. What is worth interrupting for\n is the subject MOVING, which is what the heading says.\n */}\n <Text\n variant=\"body\"\n size=\"sm\"\n weight=\"semibold\"\n as=\"span\"\n aria-live=\"polite\"\n data-testid={`live-activity-title-${activity.id}`}\n >\n {activity.title}\n </Text>\n {activity.body === null ? null : (\n <Text id={bodyId} variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\">\n {activity.body}\n </Text>\n )}\n </Box>\n </>\n );\n}\n\nexport function LiveActivityCard({\n activity,\n messages,\n live,\n renderIcon,\n now,\n onOpen,\n}: LiveActivityCardProps): JSX.Element {\n const followable = activity.link !== null && onOpen !== undefined;\n const bodyId = useId();\n const target = (\n <ActivityTarget\n activity={activity}\n bodyId={bodyId}\n {...(renderIcon ? { renderIcon } : {})}\n />\n );\n return (\n <Box data-testid={`live-activity-${activity.id}`} sx={cardSx}>\n {followable ? (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={() => onOpen(activity)}\n // `aria-label` REPLACES the contents, so the sentence under the\n // heading — the detail that makes the heading actionable — would be\n // announced to nobody. `aria-describedby` puts it back.\n aria-label={live.openActivity(activity.title)}\n {...(activity.body === null ? {} : { 'aria-describedby': bodyId })}\n data-testid={`live-activity-open-${activity.id}`}\n sx={stretchedSx}\n >\n {target}\n </Box>\n ) : (\n <Box sx={targetSx}>{target}</Box>\n )}\n <ActivityLane activity={activity} />\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\" italic>\n {live.updated(relativeTime(activity.updatedAt, messages, now))}\n </Text>\n </Box>\n );\n}\n"],"mappings":"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","names":["useId","Box","Text","Fragment","jsx","jsxs","useId","Box","Text"]}
|
package/dist/{create-web-notifications-DV3Y8k7e.d.ts → create-web-notifications-CnaXx6km.d.ts}
RENAMED
|
@@ -196,17 +196,20 @@ type NotificationsSubscribe = (onHint: () => void) => () => void;
|
|
|
196
196
|
*/
|
|
197
197
|
type NotificationsSignalHook = (onHint: () => void) => void;
|
|
198
198
|
declare function useInboxState(store: InboxStore): InboxState;
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
*
|
|
202
|
-
* `enabled` gates the poll AND the subscription. A signed-out header still
|
|
203
|
-
* mounts the bell, and there is nothing for it to hear.
|
|
204
|
-
*/
|
|
205
|
-
declare function useUnreadCount(store: InboxStore, options?: {
|
|
199
|
+
/** What both badge hooks below take, and what the bell passes them. */
|
|
200
|
+
interface BadgeSyncOptions {
|
|
206
201
|
enabled?: boolean;
|
|
207
202
|
subscribe?: NotificationsSubscribe;
|
|
208
203
|
useSignal?: NotificationsSignalHook;
|
|
209
|
-
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The bell badge number, for a host with its own trigger chrome.
|
|
207
|
+
*
|
|
208
|
+
* A host that also publishes live activities wants `useBellBadge` from the
|
|
209
|
+
* factory instead — this one counts inbox rows and knows nothing about what is
|
|
210
|
+
* happening right now.
|
|
211
|
+
*/
|
|
212
|
+
declare function useUnreadCount(store: InboxStore, options?: BadgeSyncOptions): number;
|
|
210
213
|
/** The panel's list — only fetches while the panel is open. */
|
|
211
214
|
declare function useInboxList(store: InboxStore, open: boolean): InboxState;
|
|
212
215
|
|
|
@@ -285,10 +288,29 @@ interface LiveActivitiesConfig {
|
|
|
285
288
|
renderIcon?: (activity: LiveActivity) => ReactNode;
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
/** The bell's whole state — see the file docblock for what each half means. */
|
|
292
|
+
interface BellBadge {
|
|
293
|
+
/** What the badge shows. `0` renders no badge at all. */
|
|
294
|
+
count: number;
|
|
295
|
+
/**
|
|
296
|
+
* Whether any of it has arrived or moved since the reader last looked.
|
|
297
|
+
*
|
|
298
|
+
* The trigger paints this as its accent colour; a host with its own chrome
|
|
299
|
+
* decides how to say it, but it should be a difference somebody notices.
|
|
300
|
+
*/
|
|
301
|
+
hasNew: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
288
304
|
/**
|
|
289
305
|
* Bare bell trigger with the live unread badge — for hosts that do not already
|
|
290
|
-
* have a styled icon-button slot.
|
|
291
|
-
*
|
|
306
|
+
* have a styled icon-button slot.
|
|
307
|
+
*
|
|
308
|
+
* A host with its own trigger chrome uses `useBellBadge` + `Panel` directly,
|
|
309
|
+
* and NOT `useUnreadCount`, which is what this sentence used to say. That
|
|
310
|
+
* advice was taken, verbatim and by name, by a storefront whose header needed
|
|
311
|
+
* its own trigger — and it gave that storefront a bell showing nothing at all
|
|
312
|
+
* while a live pedido sat in the panel it opens, because `useUnreadCount`
|
|
313
|
+
* counts inbox rows and knows nothing about what is happening right now.
|
|
292
314
|
*/
|
|
293
315
|
|
|
294
316
|
interface BellButtonProps {
|
|
@@ -434,10 +456,35 @@ interface WebNotifications {
|
|
|
434
456
|
enabled?: boolean;
|
|
435
457
|
onNavigate?: (link: string) => void;
|
|
436
458
|
}>;
|
|
437
|
-
/**
|
|
459
|
+
/**
|
|
460
|
+
* The unread INBOX count.
|
|
461
|
+
*
|
|
462
|
+
* For a host with its own trigger chrome only when that host configured no
|
|
463
|
+
* live activities — otherwise it is a bell that ignores everything happening
|
|
464
|
+
* right now, and `useBellBadge` below is the door. Still the right hook for
|
|
465
|
+
* anything that genuinely wants "how many unread rows".
|
|
466
|
+
*/
|
|
438
467
|
useUnreadCount: (options?: {
|
|
439
468
|
enabled?: boolean;
|
|
440
469
|
}) => number;
|
|
470
|
+
/**
|
|
471
|
+
* The badge's NUMBER AND TONE, for a host with its own trigger chrome.
|
|
472
|
+
*
|
|
473
|
+
* What `useUnreadCount` should have been for a host that also configured live
|
|
474
|
+
* activities, and the reason it is a second door rather than a change to that
|
|
475
|
+
* one: a count alone cannot express a bell, because a live entry is present
|
|
476
|
+
* without being news (see `./bell-badge`). A host that renders
|
|
477
|
+
* `useUnreadCount` in its own chrome gets a badge that ignores everything
|
|
478
|
+
* happening right now — which is not a subtle wrongness, it is the pinned
|
|
479
|
+
* pedido on screen going uncounted.
|
|
480
|
+
*
|
|
481
|
+
* Identical to what this package's own `BellButton` draws, because it is the
|
|
482
|
+
* hook that bell uses. Without live activities configured it is
|
|
483
|
+
* `useUnreadCount` plus `hasNew: count > 0`.
|
|
484
|
+
*/
|
|
485
|
+
useBellBadge: (options?: {
|
|
486
|
+
enabled?: boolean;
|
|
487
|
+
}) => BellBadge;
|
|
441
488
|
/** The shared client state, for host glue. */
|
|
442
489
|
store: InboxStore;
|
|
443
490
|
/** The bound wire client. */
|
|
@@ -447,4 +494,4 @@ interface WebNotifications {
|
|
|
447
494
|
}
|
|
448
495
|
declare function createWebNotifications(config: NotificationsWebConfig): WebNotifications;
|
|
449
496
|
|
|
450
|
-
export { BADGE_POLL_MS as B, type InboxListStatus as I, type LiveActivitiesConfig as L, type NotificationsApiClient as N, PAGE_SIZE as P, type WebNotifications as W, BADGE_RECONCILE_MS as a, type
|
|
497
|
+
export { useUnreadCount as A, BADGE_POLL_MS as B, type InboxListStatus as I, type LiveActivitiesConfig as L, type NotificationsApiClient as N, PAGE_SIZE as P, type WebNotifications as W, BADGE_RECONCILE_MS as a, type BadgeSyncOptions as b, createWebNotifications as c, type BellBadge as d, type BellButtonProps as e, type InboxState as f, type InboxStore as g, type LiveActivitiesHook as h, type LiveActivityMessages as i, NotificationsHttpError as j, type NotificationsPanelProps as k, type NotificationsResult as l, type NotificationsSignalHook as m, type NotificationsSubscribe as n, type NotificationsTransport as o, type NotificationsWebConfig as p, type PreferencesPayload as q, type PreferencesScreenProps as r, type PushRegistrationPayload as s, type WebPushPlatformHint as t, type WebPushSetupConfig as u, createInboxStore as v, createNotificationsApiClient as w, httpNotificationsTransport as x, useInboxList as y, useInboxState as z };
|
package/dist/manifest/web.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { c as createEmailPreviewScreen } from '../preview-screen-DYJRAnAY.js';
|
|
2
|
-
import { c as createWebNotifications } from '../create-web-notifications-
|
|
2
|
+
import { c as createWebNotifications } from '../create-web-notifications-CnaXx6km.js';
|
|
3
3
|
import 'react';
|
|
4
4
|
import '../wire-BG1kuoXX.js';
|
|
5
5
|
import '../types-BlqZkCWZ.js';
|
package/dist/manifest/web.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createWebNotifications
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-2TJ4D2KE.js";
|
|
4
|
+
import "../chunk-2IAHFIXS.js";
|
|
5
5
|
import {
|
|
6
6
|
createEmailPreviewScreen
|
|
7
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-KEYRE245.js";
|
|
8
8
|
import "../chunk-M2TVBVH2.js";
|
|
9
9
|
import "../chunk-7QVYU63E.js";
|
|
10
10
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
LiveSection,
|
|
3
3
|
relativeTime
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-MPUOVQVX.js";
|
|
5
5
|
import {
|
|
6
6
|
BellIcon,
|
|
7
7
|
useInboxList
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-2IAHFIXS.js";
|
|
9
9
|
import "./chunk-RTURLH5U.js";
|
|
10
10
|
import {
|
|
11
11
|
__name
|
|
@@ -295,4 +295,4 @@ __name(NotificationsPanel, "NotificationsPanel");
|
|
|
295
295
|
export {
|
|
296
296
|
NotificationsPanel
|
|
297
297
|
};
|
|
298
|
-
//# sourceMappingURL=panel-
|
|
298
|
+
//# sourceMappingURL=panel-MKI4PTNZ.js.map
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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,
|
|
1
|
+
import { L as LiveActivitiesConfig, N as NotificationsApiClient } from '../create-web-notifications-CnaXx6km.js';
|
|
2
|
+
export { B as BADGE_POLL_MS, a as BADGE_RECONCILE_MS, b as BadgeSyncOptions, d as BellBadge, e as BellButtonProps, I as InboxListStatus, f as InboxState, g as InboxStore, h as LiveActivitiesHook, i as LiveActivityMessages, j as NotificationsHttpError, k as NotificationsPanelProps, l as NotificationsResult, m as NotificationsSignalHook, n as NotificationsSubscribe, o as NotificationsTransport, p as NotificationsWebConfig, P as PAGE_SIZE, q as PreferencesPayload, r as PreferencesScreenProps, s as PushRegistrationPayload, W as WebNotifications, t as WebPushPlatformHint, u as WebPushSetupConfig, v as createInboxStore, w as createNotificationsApiClient, c as createWebNotifications, x as httpNotificationsTransport, y as useInboxList, z as useInboxState, A as useUnreadCount } from '../create-web-notifications-CnaXx6km.js';
|
|
3
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';
|
|
@@ -67,11 +67,15 @@ declare function BellIcon({ size, dim, }: {
|
|
|
67
67
|
*
|
|
68
68
|
* ## What it deliberately does NOT do
|
|
69
69
|
*
|
|
70
|
-
* - It does not
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
70
|
+
* - It does not mark anything READ. A live entry counts on the bell, but as
|
|
71
|
+
* itself rather than as unread — the tone, not the number, is what says
|
|
72
|
+
* whether it is news. (This once read "it does not touch `unread`", on the
|
|
73
|
+
* argument that counting it would put a number on the bell no amount of
|
|
74
|
+
* reading can clear. The argument stands; the tone is what answers it.)
|
|
75
|
+
* - It renders no heading, no empty state and no reserved space when there is
|
|
76
|
+
* nothing live — but it still renders its SLOT, so the inbox below keeps its
|
|
77
|
+
* position and is not torn down and rebuilt every time a subject starts or
|
|
78
|
+
* finishes.
|
|
75
79
|
* - It does not fetch. `useActivities` is the host's, and `active` tells it
|
|
76
80
|
* whether anyone is looking.
|
|
77
81
|
*/
|
|
@@ -113,6 +117,22 @@ interface LiveSectionProps {
|
|
|
113
117
|
*/
|
|
114
118
|
seen?: LiveSeenStore;
|
|
115
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* ## Why this always renders its slot, even with nothing live
|
|
122
|
+
*
|
|
123
|
+
* React reconciles a fragment's children POSITIONALLY. The section and the
|
|
124
|
+
* inbox are siblings in one fragment, and the empty branch used to render the
|
|
125
|
+
* inbox ALONE — one child rather than two — so the inbox moved to a position
|
|
126
|
+
* previously held by a different element type, which React handles by
|
|
127
|
+
* unmounting the old subtree and mounting a new one. Every `NotificationRow`
|
|
128
|
+
* would be torn down and rebuilt the moment a pedido started or finished,
|
|
129
|
+
* throwing keyboard focus to `<body>` inside a focus-trapped drawer, for a
|
|
130
|
+
* reader who was only scrolling their inbox.
|
|
131
|
+
*
|
|
132
|
+
* So the empty case renders `null` INTO the slot rather than returning early.
|
|
133
|
+
* Pinned by comparing the row's DOM NODE across the transition: a test on the
|
|
134
|
+
* test id alone passes either way, because a remounted row has the same id.
|
|
135
|
+
*/
|
|
116
136
|
declare function LiveSection({ config, messages, active, onOpen, children, seen, }: LiveSectionProps): JSX.Element;
|
|
117
137
|
|
|
118
138
|
/**
|
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-2TJ4D2KE.js";
|
|
7
7
|
import {
|
|
8
8
|
LiveSection,
|
|
9
9
|
relativeTime
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-MPUOVQVX.js";
|
|
11
11
|
import {
|
|
12
12
|
BADGE_POLL_MS,
|
|
13
13
|
BADGE_RECONCILE_MS,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
useInboxList,
|
|
18
18
|
useInboxState,
|
|
19
19
|
useUnreadCount
|
|
20
|
-
} from "../chunk-
|
|
20
|
+
} from "../chunk-2IAHFIXS.js";
|
|
21
21
|
import {
|
|
22
22
|
disableWebPush,
|
|
23
23
|
enableWebPush,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.0",
|
|
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.22.0"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@12-apps/wiring": ">=1.3.0",
|
|
@@ -56,7 +56,10 @@ export function useLoadable<T>(
|
|
|
56
56
|
// for, with the toggle disagreeing.
|
|
57
57
|
live = false;
|
|
58
58
|
};
|
|
59
|
-
|
|
59
|
+
// `keepPrevious` belongs here: it decides whether this effect blanks the
|
|
60
|
+
// children, so a caller that flips it must get the new behaviour rather
|
|
61
|
+
// than the one captured on first render.
|
|
62
|
+
}, [load, nonce, keepPrevious]);
|
|
60
63
|
return { ...state, reload: () => setNonce((n) => n + 1) };
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -295,10 +295,10 @@ export function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {
|
|
|
295
295
|
// list whose contents cannot have changed. What the click actually needs is
|
|
296
296
|
// a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does
|
|
297
297
|
// that on its own.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
);
|
|
298
|
+
// Not `apiBase`: it comes from the FACTORY's config, not from this
|
|
299
|
+
// component, so it is constant for the component's whole life and listing
|
|
300
|
+
// it says this callback can change when it cannot.
|
|
301
|
+
const load = useCallback(() => fetchEmailPreviewIndex(apiBase, locale), [locale]);
|
|
302
302
|
const index = useLoadable(load, { keepPrevious: true });
|
|
303
303
|
|
|
304
304
|
const patch = (next: Record<string, string>): void => {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bell shows: ONE number, and whether any of it is news.
|
|
3
|
+
*
|
|
4
|
+
* The badge answers two different questions with one glyph, and keeping them
|
|
5
|
+
* apart is the whole design:
|
|
6
|
+
*
|
|
7
|
+
* - the COUNT is how many things the centre is holding for the reader;
|
|
8
|
+
* - the TONE is whether any of them has happened since they last looked.
|
|
9
|
+
*
|
|
10
|
+
* An inbox row makes those the same question — an unread row is by definition
|
|
11
|
+
* both present and unseen — which is why the distinction did not exist before
|
|
12
|
+
* live activities did. A live activity separates them: a pedido that has been
|
|
13
|
+
* `Preparo` for ten minutes is still worth a `1`, and shouting about it every
|
|
14
|
+
* render is how a badge teaches people to stop reading it.
|
|
15
|
+
*
|
|
16
|
+
* ## This is here so a host can DRAW it
|
|
17
|
+
*
|
|
18
|
+
* The numbers were already correct inside this package's own `BellButton`, and
|
|
19
|
+
* unreachable from a host that cannot take that component — a header whose cart
|
|
20
|
+
* and search buttons are one styled icon-button is importing a second trigger
|
|
21
|
+
* style the moment it does. Such a host had `useUnreadCount` and nothing else,
|
|
22
|
+
* so its bell showed NOTHING while a pinned pedido sat inside the panel it
|
|
23
|
+
* opens. Both bells now read these hooks, so a host cannot drift from what this
|
|
24
|
+
* package renders.
|
|
25
|
+
*
|
|
26
|
+
* ## What it does NOT yet do
|
|
27
|
+
*
|
|
28
|
+
* A live subject usually also writes inbox rows as it moves, and this counts
|
|
29
|
+
* both: a pedido with one unread row about it reads `2`. Subtracting the double
|
|
30
|
+
* needs the server to say which unread rows name which subject, and that was
|
|
31
|
+
* built, reviewed and pulled — for reasons about the CONTRACT rather than the
|
|
32
|
+
* arithmetic, and worth recording so the next attempt starts past them:
|
|
33
|
+
*
|
|
34
|
+
* - it added a field to `GET /notifications/unread-count`, and at least one
|
|
35
|
+
* adopter publishes that response as a closed schema to LLM clients. An
|
|
36
|
+
* additive field is a breaking change against `additionalProperties: false`.
|
|
37
|
+
* - the scan is per READER, so every host paid it — including the two SPAs in
|
|
38
|
+
* that adopter that share one factory and configure no live activities at
|
|
39
|
+
* all, and read the count through `useUnreadCount`, which never sees the
|
|
40
|
+
* breakdown.
|
|
41
|
+
* - it narrowed `NotificationsApiClient.unreadCount()` from `Promise<number>`,
|
|
42
|
+
* which is a breaking change on a commit the release rules cut as a minor.
|
|
43
|
+
*
|
|
44
|
+
* The way through is an opt-in the surface asks for — a host with no live
|
|
45
|
+
* activities then sends nothing different and receives nothing different.
|
|
46
|
+
*
|
|
47
|
+
* (An earlier revision of this docblock blamed a missing index instead. That
|
|
48
|
+
* was wrong: `[userId, deletedAt, readAt]` is a full equality prefix over the
|
|
49
|
+
* filter, and the `ORDER BY` the scan carried was not load-bearing, since a
|
|
50
|
+
* tally does not care what order it counts in.)
|
|
51
|
+
*/
|
|
52
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
53
|
+
|
|
54
|
+
import { useBadgeState, type BadgeSyncOptions } from './hooks';
|
|
55
|
+
import type { InboxStore } from './inbox-state';
|
|
56
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
57
|
+
import { hasUnseenActivity, type LiveSeenStore } from './live-seen';
|
|
58
|
+
|
|
59
|
+
/** The bell's whole state — see the file docblock for what each half means. */
|
|
60
|
+
export interface BellBadge {
|
|
61
|
+
/** What the badge shows. `0` renders no badge at all. */
|
|
62
|
+
count: number;
|
|
63
|
+
/**
|
|
64
|
+
* Whether any of it has arrived or moved since the reader last looked.
|
|
65
|
+
*
|
|
66
|
+
* The trigger paints this as its accent colour; a host with its own chrome
|
|
67
|
+
* decides how to say it, but it should be a difference somebody notices.
|
|
68
|
+
*/
|
|
69
|
+
hasNew: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The badge for a host with no live activities: unread rows, and that is all.
|
|
74
|
+
*
|
|
75
|
+
* `hasNew` is `count > 0` here, and not as a simplification — an UNREAD row is
|
|
76
|
+
* one the reader has not seen, so for this host presence and novelty really are
|
|
77
|
+
* the same fact.
|
|
78
|
+
*/
|
|
79
|
+
export function useInboxBellBadge(store: InboxStore, options: BadgeSyncOptions = {}): BellBadge {
|
|
80
|
+
// `useBadgeState` already blanks itself when disabled — the gate lives there,
|
|
81
|
+
// once, rather than at each of the three hooks that layer on it.
|
|
82
|
+
const { unread } = useBadgeState(store, options);
|
|
83
|
+
// MEMOISED, unlike the number `useUnreadCount` returns. `useSyncExternalStore`
|
|
84
|
+
// re-renders on every `patch` and `patch` always allocates, so a poll that
|
|
85
|
+
// comes back with an unchanged count would otherwise hand a host a new object
|
|
86
|
+
// every 60 s — enough to re-fire a `useEffect` keyed on it, or defeat a
|
|
87
|
+
// `React.memo` on the trigger, forever.
|
|
88
|
+
return useMemo(() => ({ count: unread, hasNew: unread > 0 }), [unread]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The badge for a host that configured live activities.
|
|
93
|
+
*
|
|
94
|
+
* A SECOND hook rather than a flag on the one above, for the reason the bell
|
|
95
|
+
* itself is two components: `live.useActivities` is a hook, so a single hook
|
|
96
|
+
* reading an optional config would be calling one conditionally — which React
|
|
97
|
+
* reports as a crash somewhere else entirely. The factory knows statically
|
|
98
|
+
* which host it is building for and binds one.
|
|
99
|
+
*
|
|
100
|
+
* ## `enabled` is enforced HERE, not taken on trust
|
|
101
|
+
*
|
|
102
|
+
* A host is explicitly allowed to ignore the `active` hint and always answer —
|
|
103
|
+
* `./live-config` calls that "behaving correctly and merely paying for it" — so
|
|
104
|
+
* a signed-out header, which still MOUNTS the bell, can be handed a list of
|
|
105
|
+
* somebody's pedidos. The guard below is the only thing between that and a
|
|
106
|
+
* badge counting them.
|
|
107
|
+
*
|
|
108
|
+
* Defensive against the CONTRACT, not against an observed adopter: today's one
|
|
109
|
+
* honours the hint on every lever it has. That is exactly why the guard needs
|
|
110
|
+
* saying — nothing about the current tree would fail if it went, and the case
|
|
111
|
+
* that covers it has to build the ignoring host itself.
|
|
112
|
+
*
|
|
113
|
+
* ## What it costs the host, stated plainly
|
|
114
|
+
*
|
|
115
|
+
* The bell is mounted for as long as the app is, so unlike the panel's copy of
|
|
116
|
+
* this hook there is no "nobody is looking" state to stand down in — `active`
|
|
117
|
+
* is simply `enabled`. A host that answers by polling therefore polls for every
|
|
118
|
+
* signed-in reader whether or not they ever open the centre. That is the price
|
|
119
|
+
* of a badge that knows about live activities at all, and the reason to answer
|
|
120
|
+
* this hook from a pushed cache rather than from an interval.
|
|
121
|
+
*/
|
|
122
|
+
export function useLiveBellBadge(
|
|
123
|
+
store: InboxStore,
|
|
124
|
+
live: LiveActivitiesConfig,
|
|
125
|
+
seen: LiveSeenStore,
|
|
126
|
+
options: BadgeSyncOptions = {},
|
|
127
|
+
): BellBadge {
|
|
128
|
+
const enabled = options.enabled ?? true;
|
|
129
|
+
const { unread } = useBadgeState(store, options);
|
|
130
|
+
const activities = live.useActivities({ active: enabled });
|
|
131
|
+
const seenAt = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
|
|
132
|
+
// The store's own half is already blanked by `useBadgeState`; the `enabled`
|
|
133
|
+
// guard here is for the ACTIVITIES half, which comes from a host hook that
|
|
134
|
+
// may have ignored the hint.
|
|
135
|
+
//
|
|
136
|
+
// A live entry COUNTS. It is a notification — it is the one the reader most
|
|
137
|
+
// wants to know about — and the panel it opens lists it.
|
|
138
|
+
const count = enabled ? unread + activities.length : 0;
|
|
139
|
+
const hasNew = enabled && (unread > 0 || hasUnseenActivity(activities, seenAt));
|
|
140
|
+
// Memoised on the two RESULTS, not on `activities`. A host's hook returns a
|
|
141
|
+
// fresh array every render — the storefront's maps its query's rows, so
|
|
142
|
+
// structural sharing keeps the DATA identical and the array new — so an
|
|
143
|
+
// `activities` dependency would invalidate on every render and the memo would
|
|
144
|
+
// buy nothing at all. `hasUnseenActivity` runs unmemoised in front of it,
|
|
145
|
+
// which is a `.some()` over the handful of things happening at once.
|
|
146
|
+
return useMemo(() => ({ count, hasNew }), [count, hasNew]);
|
|
147
|
+
}
|